Browse Source

1、签约页面变更

guanxinyi 1 year ago
parent
commit
ce4695a061

+ 27 - 0
lib/components/floating_window/click_notification.dart

@@ -0,0 +1,27 @@
+/*
+ * @Descripttion: 
+ * @version: 
+ * @Author: guanxiaoxin
+ * @Date: 2023-10-18 15:33:09
+ * @LastEditors: guanxiaoxin
+ * @LastEditTime: 2023-10-18 15:33:25
+ * @FilePath: \VNoteApp\lib\components\floating_window\click_notification.dart
+ */
+import 'package:flutter/material.dart';
+
+/// [ClickNotification]列表项点击事件通知类
+class ClickNotification extends Notification {
+  ClickNotification(
+      {this.deletedIndex = -1,
+      this.clickIndex = -1,
+      this.changeWidget = false});
+
+  /// 触发了关闭事件的列表项索引
+  int deletedIndex = -1;
+
+  /// 触发了点击事件的列表项索引
+  int clickIndex = -1;
+
+  /// 是否触发了改变形态的操作
+  bool changeWidget = false;
+}

+ 423 - 0
lib/components/floating_window/floating_button.dart

@@ -0,0 +1,423 @@
+/*
+ * @Descripttion: 
+ * @version: 
+ * @Author: guanxiaoxin
+ * @Date: 2023-10-18 15:34:43
+ * @LastEditors: guanxiaoxin
+ * @LastEditTime: 2023-10-18 16:23:12
+ * @FilePath: \VNoteApp\lib\components\floating_window\floating_button.dart
+ */
+import 'dart:async';
+import 'dart:math';
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+import 'package:vnoteapp/components/floating_window/click_notification.dart';
+import 'package:vnoteapp/components/floating_window/floating_window_shared_data_widget.dart';
+
+class FloatingButton extends StatefulWidget {
+  const FloatingButton({super.key});
+
+  @override
+  _FloatingButtonState createState() => _FloatingButtonState();
+}
+
+class _FloatingButtonState extends State<FloatingButton>
+    with TickerProviderStateMixin {
+  /// [isPress] 按钮是否被按下
+  bool isPress = false;
+
+  /// [_controller] 返回动画控制器
+  AnimationController? _controller;
+
+  /// [_animation] 返回动画
+  Animation? _animation;
+
+  @override
+  Widget build(BuildContext context) {
+    /// 获取悬浮窗共享数据
+    var windowModel = FloatingWindowSharedDataWidget.of(context)!.data;
+    return Positioned(
+      left: windowModel.left,
+      top: windowModel.top,
+      child: Listener(
+        /// 按下后设[isPress]为true,绘制选中阴影
+        onPointerDown: (details) {
+          setState(() {
+            isPress = true;
+          });
+        },
+
+        /// 按下后设isPress为false,不绘制阴影
+        /// 放下后根据当前x坐标与1/2屏幕宽度比较,判断屏幕在屏幕左侧或右侧,设置返回边缘动画
+        /// 动画结束后设置isLeft的值,根据值绘制左/右边缘按钮
+        onPointerUp: (e) async {
+          setState(() {
+            isPress = false;
+          });
+
+          /// 获取屏幕信息
+          var pixelDetails = MediaQuery.of(context).size; //获取屏幕信息
+
+          /// 点击按钮,触发Widget改变事件
+          if (windowModel.isLeft &&
+              e.position.dx <= 50.0 &&
+              windowModel.isEdge) {
+            ClickNotification(changeWidget: true).dispatch(context);
+            return;
+          } else if (!windowModel.isLeft &&
+              e.position.dx >= pixelDetails.width - 50.0 &&
+              windowModel.isEdge) {
+            ClickNotification(changeWidget: true).dispatch(context);
+            return;
+          }
+
+          /// 触发返回动画
+          if (e.position.dx <= pixelDetails.width / 2) {
+            /// 申请动画资源
+            _controller = AnimationController(
+                vsync: this,
+                duration: const Duration(milliseconds: 100)); //0.1s动画
+            _animation =
+                Tween(begin: e.position.dx, end: 0.0).animate(_controller!)
+                  ..addListener(() {
+                    setState(() {
+                      /// 更新x坐标
+                      windowModel.left = _animation!.value;
+                    });
+                  });
+
+            /// 等待动画结束
+            await _controller!.forward();
+            _controller!.dispose();
+
+            /// 释放动画资源
+            setState(() {
+              windowModel.isLeft = true;
+
+              /// 按钮在屏幕左侧
+            });
+          } else {
+            /// 申请动画资源
+            _controller = AnimationController(
+                vsync: this,
+                duration: const Duration(milliseconds: 100)); //0.1动画
+            _animation =
+                Tween(begin: e.position.dx, end: pixelDetails.width - 50)
+                    .animate(_controller!) //返回右侧坐标需要减去自身宽度及50,因坐标以图形左上角为基点
+                  ..addListener(() {
+                    setState(() {
+                      windowModel.left = _animation!.value;
+
+                      /// 动画更新x坐标
+                    });
+                  });
+            await _controller!.forward();
+
+            /// 等待动画结束
+            _controller!.dispose();
+
+            /// 释放动画资源
+            setState(() {
+              windowModel.isLeft = false;
+
+              /// 按钮在屏幕右侧
+            });
+          }
+
+          setState(() {
+            windowModel.isEdge = true;
+
+            /// 按钮返回至边缘,更新按钮状态
+          });
+        },
+        child: GestureDetector(
+          /// 拖拽更新
+          onPanUpdate: (details) {
+            var pixelDetails = MediaQuery.of(context).size;
+
+            /// 获取屏幕信息
+            /// 拖拽后更新按钮信息,是否处于边缘
+            if (windowModel.left + details.delta.dx > 0 &&
+                windowModel.left + details.delta.dx < pixelDetails.width - 50) {
+              setState(() {
+                windowModel.isEdge = false;
+              });
+            } else {
+              setState(() {
+                windowModel.isEdge = true;
+              });
+            }
+
+            /// 拖拽更新坐标
+            setState(() {
+              windowModel.left += details.delta.dx;
+              windowModel.top += details.delta.dy;
+            });
+          },
+          child: FutureBuilder(
+            future: loadImageByProvider(
+                AssetImage(windowModel.dataList[0]['imageUrl'] ?? '')),
+            builder: (context, snapshot) => CustomPaint(
+              size: const Size(50.0, 50.0),
+              painter: FloatingButtonPainter(
+                  isLeft: windowModel.isLeft,
+                  isEdge: windowModel.isEdge,
+                  isPress: isPress,
+                  buttonImage: Image.asset('assets/images/avatar.png')),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  /// 通过ImageProvider获取ui.image
+  Future<ui.Image> loadImageByProvider(
+    ImageProvider provider, {
+    ImageConfiguration config = ImageConfiguration.empty,
+  }) async {
+    Completer<ui.Image> completer = Completer<ui.Image>(); //完成的回调
+    ImageStreamListener listener;
+    ImageStream stream = provider.resolve(config); //获取图片流
+    listener = ImageStreamListener((ImageInfo frame, bool sync) {
+//监听
+      final ui.Image image = frame.image;
+      completer.complete(image); //完成
+      // stream.removeListener(listener); //移除监听
+    });
+    stream.addListener(listener); //添加监听
+    return completer.future; //返回
+  }
+}
+
+class FloatingButtonPainter extends CustomPainter {
+  FloatingButtonPainter(
+      {Key? key,
+      required this.isLeft,
+      required this.isEdge,
+      required this.isPress,
+      required this.buttonImage});
+
+  /// 按钮是否在屏幕左侧,屏幕宽度 / 2
+  final bool isLeft;
+
+  /// 按钮是否在屏幕边界,左/右边界
+  final bool isEdge;
+
+  /// 按钮是否被按下
+  final bool isPress;
+
+  /// 内按钮图片 ui.image
+  final Image buttonImage;
+
+  @override
+  void paint(Canvas canvas, Size size) {
+// TODO: implement paint
+    /// 按钮是否在边缘
+    if (isEdge) {
+      /// 按钮在屏幕左边或右边
+      if (isLeft) {
+        paintLeftEdgeButton(canvas, size);
+      } else {
+        paintRightEdgeButton(canvas, size);
+      }
+
+      /// 绘制右边缘按钮
+    } else {
+      paintCenterButton(canvas, size);
+
+      /// 绘制中心按钮
+    }
+  }
+
+  ///绘制左边界悬浮按钮
+  void paintLeftEdgeButton(Canvas canvas, Size size) {
+    ///绘制按钮内层
+    var paint = Paint()
+      ..isAntiAlias = false
+      ..style = PaintingStyle.fill
+      ..color = const Color.fromRGBO(0xF3, 0xF3, 0xF3, 0.9);
+//..color = Color.fromRGBO(0xDA,0xDA,0xDA,0.9);
+
+    /// path : 按钮内边缘路径
+    var path = Path()..moveTo(size.width / 2, size.height - 1.5);
+    path.lineTo(0.0, size.height - 1.5);
+    path.lineTo(0.0, 1.5);
+    path.lineTo(size.width / 2, 1.5);
+    Rect rect = Rect.fromCircle(
+        center: Offset(size.width / 2, size.height / 2), radius: 23.5);
+    path.arcTo(rect, pi * 1.5, pi, true);
+    canvas.drawPath(path, paint);
+
+    /// edgePath: 按钮外边缘路径,黑色线条
+    var edgePath = Path()..moveTo(size.width / 2, size.height);
+    edgePath.lineTo(0.0, size.height);
+    edgePath.lineTo(0.0, 0.0);
+    edgePath.lineTo(size.width / 2, 0.0);
+    Rect rect1 = Rect.fromCircle(
+        center: Offset(size.width / 2, size.height / 2), radius: 25);
+    edgePath.arcTo(rect1, pi * 1.5, pi, true);
+
+    paint
+      ..isAntiAlias = true
+      ..strokeWidth = 0.75
+      ..strokeCap = StrokeCap.round
+      ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 0.25)
+
+      /// 线条模糊
+      ..style = PaintingStyle.stroke
+      ..color = const Color.fromRGBO(0xCF, 0xCF, 0xCF, 1);
+    canvas.drawPath(edgePath, paint);
+
+    /// 按下则画阴影,表示选中
+    if (isPress) {
+      canvas.drawShadow(
+          edgePath, const Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 0, false);
+    }
+
+    if (buttonImage.isNull) return;
+
+    /// 绘制中间图标
+    paint = Paint();
+    canvas.save();
+
+    /// 剪裁前保存图层
+    RRect imageRRect = RRect.fromRectAndRadius(
+        Rect.fromLTWH(size.width / 2 - 17.5, size.width / 2 - 17.5, 35, 35),
+        const Radius.circular(17.5));
+    canvas.clipRRect(imageRRect);
+
+    /// 图片为圆形,圆形剪裁
+    canvas.drawColor(Colors.white, BlendMode.srcOver);
+
+    // // / 设置填充颜色为白色
+    // Rect srcRect = Rect.fromLTWH(
+    //     0.0, 0.0, buttonImage.width!.toDouble(), buttonImage.height!.toDouble());
+    // Rect dstRect =
+    //     Rect.fromLTWH(size.width / 2 - 17.5, size.height / 2 - 17.5, 35, 35);
+    // canvas.drawImageRect(buttonImage, srcRect, dstRect, paint);
+    canvas.restore();
+
+    /// 图片绘制完毕恢复图层
+  }
+
+  /// 绘制右边界按钮
+  void paintRightEdgeButton(Canvas canvas, Size size) {
+    var paint = Paint()
+      ..isAntiAlias = false
+      ..style = PaintingStyle.fill
+      ..color = const Color.fromRGBO(0xF3, 0xF3, 0xF3, 0.9);
+
+    var path = Path()..moveTo(size.width / 2, 1.5);
+    path.lineTo(size.width, 1.5);
+    path.lineTo(size.width, size.height - 1.5);
+    path.lineTo(size.width / 2, size.height - 1.5);
+
+    Rect rect = Rect.fromCircle(
+        center: Offset(size.width / 2, size.height / 2), radius: 23.5);
+    path.arcTo(rect, pi * 0.5, pi, true);
+
+    canvas.drawPath(path, paint);
+
+    /// 绘制
+
+    /// edgePath: 按钮外边缘路径
+    var edgePath = Path()..moveTo(size.width / 2, 0.0);
+    edgePath.lineTo(size.width, 0.0);
+    edgePath.lineTo(size.width, size.height);
+    edgePath.lineTo(size.width / 2, size.height);
+    Rect edgeRect = Rect.fromCircle(
+        center: Offset(size.width / 2, size.height / 2), radius: 25);
+    edgePath.arcTo(edgeRect, pi * 0.5, pi, true);
+
+    paint
+      ..isAntiAlias = true
+      ..strokeWidth = 0.75
+      ..strokeCap = StrokeCap.round
+      ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 0.25)
+      ..style = PaintingStyle.stroke
+      ..color = const Color.fromRGBO(0xCF, 0xCF, 0xCF, 1);
+    canvas.drawPath(edgePath, paint);
+
+    /// 如果按下则绘制阴影
+    if (isPress) {
+      canvas.drawShadow(
+          path, const Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 0, false);
+    }
+
+    /// 防止传入null
+    if (buttonImage.isNull) return;
+
+    /// 绘制中间图标
+    paint = Paint();
+    canvas.save();
+
+    /// 剪裁前保存图层
+    RRect imageRRect = RRect.fromRectAndRadius(
+        Rect.fromLTWH(size.width / 2 - 17.5, size.width / 2 - 17.5, 35, 35),
+        const Radius.circular(17.5));
+    canvas.clipRRect(imageRRect);
+
+    /// 图片为圆形,圆形剪裁
+    canvas.drawColor(Colors.white, BlendMode.srcOver);
+
+    // /// 设置填充颜色为白色
+    // Rect srcRect = Rect.fromLTWH(
+    //     0.0, 0.0, buttonImage.width.toDouble(), buttonImage.height.toDouble());
+    // Rect dstRect =
+    //     Rect.fromLTWH(size.width / 2 - 17.5, size.height / 2 - 17.5, 35, 35);
+    // canvas.drawImageRect(buttonImage, srcRect, dstRect, paint);
+    canvas.restore();
+
+    /// 图片绘制完毕恢复图层
+  }
+
+  /// 绘制中心按钮
+  void paintCenterButton(Canvas canvas, Size size) {
+    /// 绘制按钮内层
+    var paint = Paint()
+      ..isAntiAlias = false
+      ..style = PaintingStyle.fill
+      ..color = const Color.fromRGBO(0xF3, 0xF3, 0xF3, 0.9);
+    canvas.drawCircle(Offset(size.width / 2, size.height / 2), 23.5, paint);
+
+    /// 绘制按钮外层边线
+    paint
+      ..isAntiAlias = true
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 0.75
+      ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 0.25)
+      ..color = const Color.fromRGBO(0xCF, 0xCF, 0xCF, 1);
+    canvas.drawCircle(Offset(size.width / 2, size.height / 2), 25, paint);
+
+    /// 如果按下则绘制阴影
+    if (isPress) {
+      var circleRect = Rect.fromCircle(
+          center: Offset(size.width / 2, size.height / 2), radius: 25);
+      var circlePath = Path()..moveTo(size.width / 2, size.height / 2);
+      circlePath.arcTo(circleRect, 0, 2 * 3.14, true);
+      canvas.drawShadow(
+          circlePath, const Color.fromRGBO(0xCF, 0xCF, 0xCF, 0.3), 0.5, false);
+    }
+
+    if (buttonImage.isNull) return;
+
+    /// 绘制中间图标
+    paint = Paint();
+    canvas.save();
+
+    /// 图片剪裁前保存图层
+    RRect imageRRect = RRect.fromRectAndRadius(
+        Rect.fromLTWH(size.width / 2 - 17.5, size.width / 2 - 17.5, 35, 35),
+        const Radius.circular(35));
+    canvas.clipRRect(imageRRect);
+
+    /// 图片为圆形,圆形剪裁
+    canvas.drawColor(Colors.white, BlendMode.color);
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
+}

+ 264 - 0
lib/components/floating_window/floating_item.dart

@@ -0,0 +1,264 @@
+// ignore_for_file: must_be_immutable
+
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'dart:ui' as ui;
+import 'package:vnoteapp/components/floating_window/click_notification.dart';
+
+/// [FloatingItem]一个单独功能完善的列表项类
+class FloatingItem extends StatefulWidget {
+  FloatingItem({
+    super.key,
+    required this.top,
+    required this.isLeft,
+    required this.title,
+    required this.imageProvider,
+    required this.index,
+    required this.left,
+    required this.isEntering,
+    this.width,
+  });
+
+  /// [index] 列表项的索引值
+  int index;
+
+  /// [top]列表项的y坐标值
+  double top;
+
+  /// [left]列表项的x坐标值
+  double left;
+
+  ///[isLeft] 列表项是否在左侧,否则是右侧
+  bool isLeft;
+
+  /// [title] 列表项的文字说明
+  String title;
+
+  ///[imageProvider] 列表项Logo的imageProvider
+  ImageProvider imageProvider;
+
+  ///[width] 屏幕宽度的 1 / 2
+  double? width;
+
+  ///[isEntering] 列表项是否触发进场动画
+  bool isEntering;
+
+  @override
+  _FloatingItemState createState() => _FloatingItemState();
+
+  /// 全部列表项执行退场动画
+  static void reverse() {
+    for (int i = 0; i < _FloatingItemState.animationControllers.length; ++i) {
+      if (!_FloatingItemState.animationControllers[i]
+          .toString()
+          .contains('DISPOSED')) {
+        _FloatingItemState.animationControllers[i].reverse();
+      }
+    }
+  }
+
+  /// 全部列表项执行进场动画
+  static void forward() {
+    for (int i = 0; i < _FloatingItemState.animationControllers.length; ++i) {
+      if (!_FloatingItemState.animationControllers[i]
+          .toString()
+          .contains('DISPOSED')) {
+        _FloatingItemState.animationControllers[i].forward();
+      }
+    }
+  }
+
+  /// 每次更新时释放所有动画资源,清空动画控制器列表
+  static void resetList() {
+    for (int i = 0; i < _FloatingItemState.animationControllers.length; ++i) {
+      if (!_FloatingItemState.animationControllers[i]
+          .toString()
+          .contains('DISPOSED')) {
+        _FloatingItemState.animationControllers[i].dispose();
+      }
+    }
+    _FloatingItemState.animationControllers.clear();
+    _FloatingItemState.animationControllers = [];
+  }
+}
+
+class _FloatingItemState extends State<FloatingItem>
+    with TickerProviderStateMixin {
+  /// [isPress] 列表项是否被按下
+  bool isPress = false;
+
+  ///[image] 列表项Logo的[ui.Image]对象,用于绘制Logo
+  ui.Image? image;
+
+  /// [animationController] 列表关闭动画的控制器
+  AnimationController? animationController;
+
+  /// [animationController] 所有列表项的动画控制器列表
+  static List<AnimationController> animationControllers = [];
+
+  /// [animation] 列表项的关闭动画
+  Animation? animation;
+
+  @override
+  void initState() {
+// TODO: implement initState
+    isPress = false;
+
+    /// 获取Logo的ui.Image对象
+    loadImageByProvider(widget.imageProvider).then((value) {
+      setState(() {
+        image = value;
+      });
+    });
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Positioned(
+      left: widget.left,
+      top: widget.top,
+      child: GestureDetector(
+        /// 监听按下事件,在点击区域内则将[isPress]设为true,若在关闭区域内则不做任何操作
+        onPanDown: (details) {
+          if (widget.isLeft) {
+            /// 点击区域内
+            if (details.globalPosition.dx < widget.width!) {
+              setState(() {
+                isPress = true;
+              });
+            }
+          } else {
+            /// 点击区域内
+            if (details.globalPosition.dx < widget.width! * 2 - 50) {
+              setState(() {
+                isPress = true;
+              });
+            }
+          }
+        },
+
+        /// 监听抬起事件
+        onTapUp: (details) async {
+          /// 通过左右列表项来决定关闭的区域,以及选中区域,触发相应的关闭或选中事件
+          if (widget.isLeft) {
+            /// 位于关闭区域
+            if (details.globalPosition.dx >= widget.width! && !isPress) {
+              /// 等待关闭动画执行完毕
+              await animationController?.reverse();
+
+              /// 通知父级触发关闭事件
+              ClickNotification(deletedIndex: widget.index).dispatch(context);
+            } else {
+              /// 通知父级触发相应的点击事件
+              ClickNotification(clickIndex: widget.index).dispatch(context);
+            }
+          } else {
+            /// 位于关闭区域
+            if (details.globalPosition.dx >= widget.width! * 2 - 50.0 &&
+                !isPress) {
+              /// 设置从中间返回至边缘的关闭动画
+              await animationController?.reverse();
+
+              /// 通知父级触发关闭事件
+              ClickNotification(deletedIndex: widget.index).dispatch(context);
+            } else {
+              /// 通知父级触发选中事件
+              ClickNotification(clickIndex: widget.index).dispatch(context);
+            }
+          }
+
+          /// 抬起后取消选中
+          setState(() {
+            isPress = false;
+          });
+        },
+        onTapCancel: () {
+          /// 超出范围取消选中
+          setState(() {
+            isPress = false;
+          });
+        },
+        child: Container(
+          color: Colors.red,
+        ),
+      ),
+    );
+    //  CustomPaint(
+    //     size: Size(widget.width! + 50.0, 50.0),
+    //     painter: FloatingItemPainter(
+    //       title: widget.title,
+    //       isLeft: widget.isLeft,
+    //       isPress: isPress,
+    //       image: image,
+    //     ))));
+  }
+
+  /// 通过ImageProvider获取ui.image
+  Future<ui.Image> loadImageByProvider(
+    ImageProvider provider, {
+    ImageConfiguration config = ImageConfiguration.empty,
+  }) async {
+    Completer<ui.Image> completer = Completer<ui.Image>(); //完成的回调
+    ImageStreamListener listener;
+    ImageStream stream = provider.resolve(config); //获取图片流
+    listener = ImageStreamListener((ImageInfo frame, bool sync) {
+//监听
+      final ui.Image image = frame.image;
+      completer.complete(image); //完成
+      // stream.removeListener(listener); //移除监听
+    });
+    stream.addListener(listener); //添加监听
+    return completer.future; //返回
+  }
+
+  @override
+  void didUpdateWidget(FloatingItem oldWidget) {
+// TODO: implement didUpdateWidget
+    animationController = AnimationController(
+        vsync: this, duration: const Duration(milliseconds: 100));
+
+    /// 初始化进场动画
+    if (widget.isLeft) {
+      animation = Tween<double>(begin: -(widget.width! + 50.0), end: 0.0)
+          .animate(animationController!)
+        ..addListener(() {
+          setState(() {
+            widget.left = animation!.value;
+          });
+        });
+    } else {
+      animation =
+          Tween<double>(begin: widget.width! * 2, end: widget.width! - 50.0)
+              .animate(animationController!)
+            ..addListener(() {
+              setState(() {
+                widget.left = animation!.value;
+              });
+            });
+    }
+    animationControllers.add(animationController!);
+
+    /// 执行进场动画
+    if (animationController!.status == AnimationStatus.dismissed &&
+        widget.isEntering) {
+      animationController!.forward();
+    }
+
+    /// 无需执行进场动画,将列表项置于动画末尾
+    else {
+      animationController!.forward(from: 100.0);
+    }
+    super.didUpdateWidget(oldWidget);
+  }
+
+  @override
+  void dispose() {
+// TODO: implement dispose
+    /// 释放动画资源,避免内存泄漏
+    if (!animationController.toString().toString().contains('DISPOSED')) {
+      animationController!.dispose();
+    }
+    super.dispose();
+  }
+}

+ 55 - 0
lib/components/floating_window/floating_item_animated_widget.dart

@@ -0,0 +1,55 @@
+import 'package:flutter/material.dart';
+import 'package:vnoteapp/components/floating_window/floating_item.dart';
+import 'package:vnoteapp/components/floating_window/floating_window_shared_data_widget.dart';
+
+/// [FloatingItemAnimatedWidget] 列表项进行动画类封装,方便传入平移向上动画
+class FloatingItemAnimatedWidget extends AnimatedWidget {
+  const FloatingItemAnimatedWidget(
+      {Key? key,
+      required Animation<double> upAnimation,
+      required this.index,
+      required this.isEntering})
+      : super(key: key, listenable: upAnimation);
+
+  /// [index] 列表项索引
+  final int index;
+
+  /// [isEntering] 列表项是否需要执行进场动画
+  final bool isEntering;
+
+  @override
+  Widget build(BuildContext context) {
+// TODO: implement build
+    /// 获取列表数据
+    var data = FloatingWindowSharedDataWidget.of(context)!.data;
+
+    /// 监听动画
+    final Animation<double> animation = listenable as Animation<double>;
+
+    /// 获取屏幕信息
+    double width = MediaQuery.of(context).size.width / 2;
+    double left = 0.0;
+    if (data.isLeft) {
+      if (isEntering) {
+        left = -(width + 50.0);
+      } else {
+        left = 0.0;
+      }
+    } else {
+      if (isEntering) {
+        left = (width * 2);
+      } else {
+        left = width - 50.0;
+      }
+    }
+    return FloatingItem(
+        top: animation.value,
+        isLeft: data.isLeft,
+        title: data.dataList[index]['title'] ?? '',
+        imageProvider: AssetImage(data.dataList[index]['imageUrl'] ?? ''),
+        index: index,
+        width: width,
+        left: left,
+        isEntering: isEntering);
+  }
+}

+ 103 - 0
lib/components/floating_window/floating_items.dart

@@ -0,0 +1,103 @@
+// ignore_for_file: must_be_immutable
+
+import 'package:flutter/material.dart';
+import 'package:vnoteapp/components/floating_window/floating_item_animated_widget.dart';
+import 'package:vnoteapp/components/floating_window/floating_window_shared_data_widget.dart';
+
+/// [FloatingItems] 列表
+class FloatingItems extends StatefulWidget {
+  FloatingItems({Key? key, required this.isEntering}) : super(key: key);
+  @override
+  _FloatingItemsState createState() => _FloatingItemsState();
+
+  ///[isEntering] 是否具有进场动画
+  bool isEntering = true;
+}
+
+class _FloatingItemsState extends State<FloatingItems>
+    with TickerProviderStateMixin {
+  /// [_controller] 列表项动画的控制器
+  AnimationController? _controller;
+
+  /// 动态生成列表
+  /// 其中一项触发关闭事件后,索引在该项后的列表项执行向上平移的动画。
+  List<Widget> getItems(BuildContext context) {
+    /// 释放和申请新的动画资源
+    if (_controller != null) {
+      _controller!.dispose();
+      _controller = AnimationController(
+          vsync: this, duration: const Duration(milliseconds: 100));
+    }
+
+    /// widget列表
+    List<Widget> widgetList = [];
+
+    /// 获取共享数据
+    var data = FloatingWindowSharedDataWidget.of(context)!.data;
+
+    /// 列表数据
+    var dataList = data.dataList;
+
+    /// 确定列表项位置
+    double top = data.top + 70.0;
+    if (data.top + 70.0 * (dataList.length + 1) >
+        MediaQuery.of(context).size.height - 20.0) {
+      top = data.top - 70.0 * (dataList.length + 1);
+    }
+
+    /// 遍历数据生成列表项
+    for (int i = 0; i < dataList.length; ++i) {
+      /// 在触发关闭事件列表项的索引之后的列表项传入向上平移动画
+      if (data.deleteIndex != -1 && i >= data.deleteIndex) {
+        Animation<double> animation;
+        animation =
+            Tween<double>(begin: top + (70.0 * (i + 1)), end: top + 70.0 * (i))
+                .animate(_controller!);
+        widgetList.add(FloatingItemAnimatedWidget(
+          upAnimation: animation,
+          index: i,
+          isEntering: widget.isEntering,
+        ));
+      }
+
+      /// 在触发关闭事件列表项的索引之前的列表项则位置固定
+      else {
+        Animation<double> animation;
+        animation =
+            Tween<double>(begin: top + (70.0 * (i)), end: top + 70.0 * (i))
+                .animate(_controller!);
+        widgetList.add(FloatingItemAnimatedWidget(
+          upAnimation: animation,
+          index: i,
+          isEntering: widget.isEntering,
+        ));
+      }
+    }
+
+    /// 重置deletedIndex
+    if (data.deleteIndex != -1) {
+      data.deleteIndex = -1;
+    }
+
+    /// 执行动画
+    if (_controller != null) _controller!.forward();
+
+    /// 返回列表
+    return widgetList;
+  }
+
+  @override
+  void initState() {
+// TODO: implement initState
+    super.initState();
+    _controller = AnimationController(
+        vsync: this, duration: const Duration(milliseconds: 100));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: getItems(context),
+    );
+  }
+}

+ 104 - 0
lib/components/floating_window/floating_window.dart

@@ -0,0 +1,104 @@
+import 'package:flutter/material.dart';
+import 'package:vnoteapp/components/floating_window/click_notification.dart';
+import 'package:vnoteapp/components/floating_window/floating_button.dart';
+import 'package:vnoteapp/components/floating_window/floating_item.dart';
+import 'package:vnoteapp/components/floating_window/floating_items.dart';
+import 'package:vnoteapp/components/floating_window/floating_window_model.dart';
+import 'package:vnoteapp/components/floating_window/floating_window_shared_data_widget.dart';
+
+/// [FloatingWindow] 悬浮窗
+class FloatingWindow extends StatefulWidget {
+  const FloatingWindow({super.key});
+
+  @override
+  _FloatingWindowState createState() => _FloatingWindowState();
+}
+
+class _FloatingWindowState extends State<FloatingWindow> {
+  List<Map<String, String>> ls = [
+    {'title': "测试一下", "imageUrl": "assets/images/no_data.png"},
+  ];
+
+  /// 悬浮窗共享数据
+  FloatingWindowModel? windowModel;
+
+  /// [isEntering] 列表项是否拥有进场动画
+  bool isEntering = true;
+  @override
+  void initState() {
+    super.initState();
+    windowModel = FloatingWindowModel(dataList: ls, isLeft: true);
+    isEntering = true;
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return FloatingWindowSharedDataWidget(
+      data: windowModel!,
+      child: windowModel!.isEmpty
+          ? Container()
+          : Stack(
+              fit: StackFit.expand,
+              children: [
+                /// 列表项遮盖层,增加淡化切换动画
+                AnimatedSwitcher(
+                  duration: const Duration(milliseconds: 100),
+                  child: windowModel!.isButton
+                      ? Container()
+                      : GestureDetector(
+                          onTap: () {
+                            FloatingItem.reverse();
+                            Future.delayed(const Duration(milliseconds: 110),
+                                () {
+                              setState(() {
+                                windowModel!.isButton = true;
+                              });
+                            });
+                          },
+                          child: Container(
+                            decoration: const BoxDecoration(
+                                color: Color.fromRGBO(0xEF, 0xEF, 0xEF, 0.9)),
+                          ),
+                        ),
+                ),
+                NotificationListener<ClickNotification>(
+                  onNotification: (notification) {
+                    /// 列表项关闭事件
+                    if (notification.deletedIndex != -1) {
+                      windowModel?.deleteIndex = notification.deletedIndex;
+                      setState(() {
+                        FloatingItem.resetList();
+                        windowModel?.dataList
+                            .removeAt(notification.deletedIndex);
+                        isEntering = false;
+                      });
+                    }
+
+                    /// 列表点击事件
+                    if (notification.clickIndex != -1) {
+                      print(notification.clickIndex);
+                    }
+
+                    /// 悬浮按钮点击Widget改变事件
+                    if (notification.changeWidget) {
+                      setState(() {
+                        /// 释放列表进出场动画资源
+                        FloatingItem.resetList();
+                        windowModel?.isButton = false;
+                        isEntering = true;
+                      });
+                    }
+
+                    return false;
+                  },
+                  child: windowModel!.isButton
+                      ? const FloatingButton()
+                      : FloatingItems(
+                          isEntering: isEntering,
+                        ),
+                )
+              ],
+            ),
+    );
+  }
+}

+ 31 - 0
lib/components/floating_window/floating_window_model.dart

@@ -0,0 +1,31 @@
+class FloatingWindowModel {
+  FloatingWindowModel({
+    this.isLeft = true,
+    this.top = 100.0,
+    List<Map<String, String>>? dataList,
+  }) : dataList = dataList!;
+
+  /// [isEmpty] 列表是非为空
+  get isEmpty => dataList.isEmpty;
+
+  /// [isLeft]:悬浮窗位于屏幕左侧/右侧
+  bool isLeft;
+
+  /// [isEdge] 悬浮窗是否在边缘
+  bool isEdge = true;
+
+  /// [isButton]
+  bool isButton = true;
+
+  /// [top] 悬浮窗纵坐标
+  double top;
+
+  /// [left] 悬浮窗横坐标
+  double left = 0.0;
+
+  /// [dataList] 列表数据
+  List<Map<String, String>> dataList;
+
+  /// 删除的列表项索引
+  int deleteIndex = -1;
+}

+ 24 - 0
lib/components/floating_window/floating_window_shared_data_widget.dart

@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+import 'package:vnoteapp/components/floating_window/floating_window_model.dart';
+
+/// [FloatingWindowSharedDataWidget]悬浮窗数据共享Widget
+class FloatingWindowSharedDataWidget extends InheritedWidget {
+  const FloatingWindowSharedDataWidget(
+      {super.key, required this.data, required Widget child})
+      : super(child: child);
+
+  ///[data]悬浮窗共享数据
+  final FloatingWindowModel data;
+
+  /// 静态方法[of]方便直接调用获取共享数据
+  static FloatingWindowSharedDataWidget? of(BuildContext context) {
+    return context
+        .dependOnInheritedWidgetOfExactType<FloatingWindowSharedDataWidget>();
+  }
+
+  @override
+  bool updateShouldNotify(FloatingWindowSharedDataWidget oldWidget) {
+    /// 数据发生变化则发布通知
+    return oldWidget.data != data && data.deleteIndex != -1;
+  }
+}

+ 139 - 517
lib/pages/contract/package_list/view.dart

@@ -1,566 +1,188 @@
 import 'dart:convert';
-import 'dart:io';
 
-import 'package:fis_jsonrpc/rpc.dart';
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
-import 'package:intl/intl.dart';
-import 'package:vnoteapp/components/alert_dialog.dart';
 import 'package:vnoteapp/components/appbar.dart';
-import 'package:vnoteapp/components/cell.dart';
-import 'package:vnoteapp/components/dialog_date.dart';
-import 'package:vnoteapp/components/dialog_input.dart';
-import 'package:vnoteapp/components/dialog_select.dart';
-import 'package:vnoteapp/components/panel.dart';
-import 'package:vnoteapp/store/store.dart';
+import 'package:vnoteapp/components/button.dart';
+import 'package:vnoteapp/components/side_nav/defines.dart';
+import 'package:vnoteapp/components/side_nav/side_nav.dart';
+import 'package:vnoteapp/pages/contract/package_list/widgets/capture_portrait.dart';
+import 'package:vnoteapp/pages/contract/package_list/widgets/family_doctor_service_package.dart';
+import 'package:vnoteapp/pages/contract/package_list/widgets/personal_information.dart';
+import 'package:vnoteapp/pages/contract/package_list/widgets/service_information.dart';
+import 'package:vnoteapp/routes/nav_ids.dart';
+import 'package:vnoteapp/routes/route_setting.dart';
 import 'controller.dart';
-import 'package:image_picker/image_picker.dart';
 
 class ServicePackageContractPage
     extends GetView<ServicePackageContractController> {
   const ServicePackageContractPage({super.key});
   static const double labelSize = 20;
-
   @override
   Widget build(BuildContext context) {
     return Scaffold(
-      backgroundColor: const Color.fromRGBO(238, 238, 238, 1),
+      backgroundColor: Colors.white,
       appBar: VAppBar(
         titleWidget: const Text(
           "签约",
           style: TextStyle(fontSize: 24),
         ),
-        actions: [
-          TextButton(
-            onPressed: () {
-              Get.back();
-              Get.toNamed(
-                "/contract/contract_template",
-                parameters: {
-                  "templateCode": "53C3323BB6444A109B2369703EFFDFF9",
-                  "patientInfo": json.encode(controller.patient.toJson()),
-                  "servicePackageCodes":
-                      controller.state.selectedServicePackageCode,
-                  "servicePackageNames":
-                      controller.state.selectedServicePackageName,
-                  "serviceTime": controller.state.serviceTime.toString(),
-                  "serviceStartDate":
-                      controller.state.serviceStartDate.toString(),
-                  "base64Image": controller.base64Image,
-                  "notes": controller.state.notes,
-                },
-              );
-            },
-            child: const Text(
-              '下一步',
-              style: TextStyle(color: Colors.white, fontSize: 20),
-            ),
-          ),
-          const SizedBox(
-            width: 15,
-          ),
-        ],
       ),
-      endDrawer: _servicePackageDrawer(context),
-      body: Builder(builder: (context) {
-        return Padding(
-          padding: const EdgeInsets.symmetric(horizontal: 200, vertical: 8),
-          child: ListView(
-            children: [
-              Column(
-                children: [
-                  _buildPhotoVPanel(),
-                  const SizedBox(height: 16),
-                  _buildContractDoctorVPanel(),
-                  const SizedBox(height: 16),
-                  _buildPatientVPanel(),
-                  const SizedBox(height: 16),
-                  _buildServicePackageVPanel(context),
-                ],
-              ),
-            ],
+      body: Stack(
+        children: [
+          VSideNavView(
+            navId: NavIds.CONTRACT,
+            items: _buildItems(),
           ),
-        );
-      }),
-    );
-  }
-
-  Drawer _servicePackageDrawer(BuildContext context) {
-    const double titleSize = 20;
-    const double labelSize = 18;
-    Widget buildAlertDialog(ServicePackDTO servicePackDTO) {
-      return VAlertDialog(
-        title: '${servicePackDTO.name}详情',
-        content: Container(
-            height: 200,
-            alignment: Alignment.topLeft,
-            padding: const EdgeInsets.symmetric(horizontal: 15),
-            child: Column(
-              crossAxisAlignment: CrossAxisAlignment.start,
+          Positioned(
+            bottom: 8,
+            left: 200,
+            right: 200,
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.spaceAround,
               children: [
-                LayoutBuilder(
-                  builder: (BuildContext context, BoxConstraints constraints) {
-                    return ConstrainedBox(
-                      constraints: const BoxConstraints(
-                        maxHeight: 100,
-                      ),
-                      child: Scrollbar(
-                        thumbVisibility: true,
-                        child: ListView(
-                          shrinkWrap: true,
-                          children: [
-                            Text(
-                              servicePackDTO.content ?? "",
-                              style: const TextStyle(fontSize: 16),
-                            ),
-                          ],
-                        ),
-                      ),
+                VButton(
+                  label: "下一步",
+                  onTap: () {
+                    Get.back();
+                    Get.toNamed(
+                      "/contract/contract_template",
+                      parameters: {
+                        "templateCode": "53C3323BB6444A109B2369703EFFDFF9",
+                        "patientInfo": json.encode(controller.patient.toJson()),
+                        "servicePackageCodes":
+                            controller.state.selectedServicePackageCode,
+                        "servicePackageNames":
+                            controller.state.selectedServicePackageName,
+                        "serviceTime": controller.state.serviceTime.toString(),
+                        "serviceStartDate":
+                            controller.state.serviceStartDate.toString(),
+                        "base64Image": controller.base64Image,
+                        "notes": controller.state.notes,
+                      },
                     );
                   },
                 ),
-                const SizedBox(
-                  height: 5,
-                ),
-                const Text(
-                  '服务项目',
-                  style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
-                ),
-                const SizedBox(
-                  height: 5,
-                ),
-                Expanded(
-                  child: Text(
-                    controller.getServiceItemsName(
-                      servicePackDTO.items ?? [],
-                    ),
-                  ),
-                )
               ],
-            )),
-        // actions: <Widget>[
-        //   TextButton(
-        //     child: const Text('取消'),
-        //     onPressed: () {
-        //       Navigator.of(context).pop(); // 关闭对话框
-        //     },
-        //   ),
-        //   TextButton(
-        //     child: const Text('确定'),
-        //     onPressed: () {
-        //       // 在这里处理确定按钮的逻辑
-        //       Navigator.of(context).pop(); // 关闭对话框
-        //     },
-        //   ),
-        // ],
-      );
-    }
-
-    Widget buildItem(ServicePackDTO servicePackDTO) {
-      return InkWell(
-        onTap: () {
-          controller.changeServicePackage(servicePackDTO);
-        },
-        child: Container(
-          margin: const EdgeInsets.symmetric(
-            vertical: 10,
-            horizontal: 50,
-          ),
-          padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
-          decoration: BoxDecoration(
-            color: Colors.white,
-            borderRadius: const BorderRadius.all(
-              Radius.circular(
-                8,
-              ),
             ),
-            border: Border.all(
-              color: controller.state.selectedServicePackage
-                      .contains(servicePackDTO)
-                  ? Theme.of(context).primaryColor
-                  : Colors.transparent,
-              width: 2,
-            ),
-          ),
-          child: Row(
-            children: [
-              Container(
-                padding: const EdgeInsets.only(right: 20),
-                child: Obx(
-                  () => Icon(
-                    Icons.check_circle_outline,
-                    size: 35,
-                    color: controller.state.selectedServicePackage
-                            .contains(servicePackDTO)
-                        ? Theme.of(context).primaryColor
-                        : Colors.grey.shade500,
-                  ),
-                ),
-              ),
-              Expanded(
-                child: Column(
-                  children: [
-                    Row(
-                      children: [
-                        Expanded(
-                          child: Text(
-                            servicePackDTO.name ?? '',
-                            style: const TextStyle(
-                              fontSize: 25,
-                              fontWeight: FontWeight.bold,
-                            ),
-                          ),
-                        ),
-                        Expanded(
-                          child: Row(
-                            children: [
-                              const Text(
-                                '服务人群:',
-                                style: TextStyle(fontSize: titleSize),
-                              ),
-                              Text(
-                                controller.setNormalLabels(
-                                  servicePackDTO.labels ?? [],
-                                ),
-                                style: const TextStyle(fontSize: labelSize),
-                              ),
-                            ],
-                          ),
-                        ),
-                      ],
-                    ),
-                    const SizedBox(
-                      height: 10,
-                    ),
-                    Row(
-                      mainAxisSize: MainAxisSize.max,
-                      children: [
-                        Expanded(
-                          child: Row(
-                            mainAxisSize: MainAxisSize.max,
-                            children: [
-                              const Text(
-                                '服务包介绍:',
-                                style: TextStyle(fontSize: titleSize),
-                              ),
-                              Expanded(
-                                child: Container(
-                                  alignment: Alignment.centerLeft,
-                                  height: 50,
-                                  child: Text(
-                                    servicePackDTO.content ?? '',
-                                    overflow: TextOverflow.ellipsis,
-                                    maxLines: 2,
-                                    style: const TextStyle(fontSize: labelSize),
-                                  ),
-                                ),
-                              ),
-                            ],
-                          ),
-                        ),
-                      ],
-                    ),
-                  ],
-                ),
-              ),
-              Container(
-                padding: const EdgeInsets.only(left: 20),
-                child: TextButton(
-                  child: const Text(
-                    '查看',
-                    style: TextStyle(fontSize: 18),
-                  ),
-                  onPressed: () async {
-                    // await Get.toNamed(
-                    //   '/contract/package_info',
-                    //   parameters: {
-                    //     "servicePack": json.encode(servicePackDTO.toJson()),
-                    //   },
-                    // );
-                    showDialog(
-                      context: context,
-                      builder: (BuildContext context) {
-                        return buildAlertDialog(servicePackDTO);
-                      },
-                    );
-                  },
-                ),
-              ),
-            ],
           ),
-        ),
-      );
-    }
-
-    Widget buildCancelButton() {
-      return TextButton(
-        onPressed: () {
-          Get.back();
-        },
-        child: const Text(
-          '取消',
-          style: TextStyle(fontSize: 25),
-        ),
-      );
-    }
-
-    Widget buildConfirmButton() {
-      return TextButton(
-        onPressed: () {},
-        child: const Text(
-          '确定',
-          style: TextStyle(fontSize: 25),
-        ),
-      );
-    }
-
-    Widget buildHeader() {
-      return Container(
-        decoration: const BoxDecoration(
-          color: Colors.white,
-        ),
-        height: 90,
-        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
-        child: Row(
-          mainAxisAlignment: MainAxisAlignment.spaceBetween,
-          children: [
-            buildCancelButton(),
-            buildConfirmButton(),
-          ],
-        ),
-      );
-    }
-
-    Widget buildServicePackageList() {
-      return Obx(
-        () => Expanded(
-          child: controller.state.servicePackageItems.isEmpty
-              ? Container(
-                  margin: const EdgeInsets.only(top: 80),
-                  child: Column(
-                    children: [
-                      Center(
-                        child: Image.asset(
-                          "assets/images/no_data.png",
-                          width: 300,
-                          height: 300,
-                          fit: BoxFit.cover,
-                        ),
-                      ),
-                      const Text(
-                        "暂无数据,先看看别的吧",
-                        style: TextStyle(fontSize: 18),
-                      ),
-                    ],
-                  ),
-                )
-              : ListView(
-                  children: controller.state.servicePackageItems
-                      .map((ServicePackDTO e) => buildItem(e))
-                      .toList(),
-                ),
-        ),
-      );
-    }
-
-    return Drawer(
-      shape: const RoundedRectangleBorder(
-        borderRadius: BorderRadiusDirectional.horizontal(
-          end: Radius.circular(0),
-        ),
-      ),
-      width: MediaQuery.of(context).size.width * 0.7,
-      child: Container(
-        color: Colors.grey.shade300,
-        child: Column(
-          mainAxisSize: MainAxisSize.max,
-          children: [
-            // buildHeader(),
-            const SizedBox(
-              height: 30,
-            ),
-            buildServicePackageList(),
-            const SizedBox(
-              height: 10,
-            ),
-          ],
-        ),
+          // const FloatingWindow(),
+        ],
       ),
     );
   }
 
-  Future<String> convertImageToBase64(XFile image) async {
-    List<int> imageBytes = await image.readAsBytes();
-    String base64Image = base64Encode(imageBytes);
-    return base64Image;
+  List<VSideNavMenuItem> _buildItems() {
+    final items = <VSideNavMenuItem>[];
+    items.add(_buildCapturePortrait());
+    items.add(_buildPersonalInformation());
+    items.add(_buildServiceInformation());
+    items.add(_buildFamilyDoctorServicePackage());
+
+    return items;
   }
 
-  Widget _buildPhotoVPanel() {
-    return VListFormCellGroup(
-      children: [
-        Obx(
-          () => VListFormCell(
-            label: '拍照',
-            height: 70,
-            contentWidget: controller.state.userImage != null
-                ? Image.file(
-                    File(controller.state.userImage!.path),
-                  )
-                : const Center(
-                    child: Icon(
-                      Icons.account_box_rounded,
-                      size: 70,
-                    ),
-                  ),
-            onTap: () async {
-              XFile? image =
-                  await ImagePicker().pickImage(source: ImageSource.camera);
-              controller.state.userImage = image;
-              String base64Image = await convertImageToBase64(image!);
-              controller.base64Image = base64Image;
-            },
-          ),
-        )
-      ],
+  VSideNavMenuItem _buildCapturePortrait() {
+    return VSideNavMenuItem(
+      title: "人像采集",
+      icon: Icon(Icons.edit_document, color: Colors.grey.shade700),
+      // onTap: () {}
+      route: VRouteSetting(
+        "/capture_portrait_page",
+        () => const CapturePortraitPage(),
+        binding: BindingsBuilder(
+          () {
+            Get.lazyPut(() => ServicePackageContractController());
+          },
+        ),
+      ),
     );
   }
 
-  Widget _buildContractDoctorVPanel() {
-    return VListFormCellGroup(
-      children: [
-        Obx(
-          () => VListFormCell(
-            label: '服务日期',
-            content: DateFormat('yyyy-MM-dd')
-                .format(controller.state.serviceStartDate),
-            onTap: () async {
-              final result = await VDialogDate(
-                title: '服务日期',
-                initialValue: controller.state.serviceStartDate,
-              ).show();
-              controller.state.serviceStartDate = result;
-            },
-          ),
-        ),
-        Obx(
-          () => VListFormCell(
-            label: '服务年限',
-            content: '${controller.state.serviceTime}年',
-            onTap: () async {
-              String? result = await VDialogSelect<VSelectModel, String>(
-                source: [
-                  VSelectModel(code: "1", name: "1年"),
-                  VSelectModel(code: "2", name: "2年"),
-                  VSelectModel(code: "3", name: "3年"),
-                ],
-                labelGetter: (data) => data.name,
-                valueGetter: (data) => data.code,
-              ).show();
-              controller.state.serviceTime = int.parse(result ?? '1');
-            },
-          ),
-        ),
-        VListFormCell(
-          label: '签约医生',
-          content: Store.user.principalName,
+  VSideNavMenuItem _buildServiceInformation() {
+    return VSideNavMenuItem(
+      title: "服务信息",
+      icon: Icon(Icons.edit_document, color: Colors.grey.shade700),
+      // onTap: () {}
+      route: VRouteSetting(
+        "/service_information_page",
+        () => const ServiceInformationPage(),
+        binding: BindingsBuilder(
+          () {
+            Get.lazyPut(() => ServicePackageContractController());
+          },
         ),
-        VListFormCell(
-          label: '医生电话',
-          content: Store.user.principalPhone,
-        ),
-      ],
+      ),
     );
   }
 
-  Widget _buildPatientVPanel() {
-    return Stack(
-      children: [
-        Obx(
-          () => VListFormCellGroup(
-            children: [
-              VListFormCell(
-                label: '姓名',
-                content: controller.state.name,
-              ),
-              VListFormCell(
-                label: '联系电话',
-                content: controller.state.phone,
-              ),
-              if (controller.state.isExpendPatient) ...[
-                VListFormCell(
-                  label: '身份证号码',
-                  content: controller.state.cardNo,
-                ),
-                const VListFormCell(
-                  label: '民族',
-                  content: '汉族',
-                ),
-                VListFormCell(
-                  label: '性别',
-                  content: controller.state.genderDesc,
-                ),
-                VListFormCell(
-                  label: '出生日期',
-                  content: DateFormat('yyyy-MM-dd').format(
-                    controller.state.birthday,
-                  ),
-                ),
-              ]
-            ],
-          ),
+  VSideNavMenuItem _buildFamilyDoctorServicePackage() {
+    return VSideNavMenuItem(
+      title: "家庭医生服务包",
+      icon: Icon(Icons.edit_document, color: Colors.grey.shade700),
+      // onTap: () {}
+      route: VRouteSetting(
+        "/family_doctor_service_package_page",
+        () => const FamilyDoctorServicePackagePage(),
+        binding: BindingsBuilder(
+          () {
+            Get.lazyPut(() => ServicePackageContractController());
+          },
         ),
-        Positioned(
-          right: 0,
-          top: 0,
-          child: IconButton(
-            onPressed: () {
-              controller.state.isExpendPatient =
-                  !controller.state.isExpendPatient;
-            },
-            icon: Obx(
-              () => Icon(
-                controller.state.isExpendPatient
-                    ? Icons.keyboard_arrow_up_rounded
-                    : Icons.keyboard_arrow_down_rounded,
-                color: Colors.grey.shade400,
-                size: 30,
-              ),
-            ),
-          ),
-        )
-      ],
+      ),
     );
   }
 
-  Widget _buildServicePackageVPanel(BuildContext context) {
-    return VPanel(
-      child: VListFormCellGroup(
-        children: [
-          Obx(
-            () => VListFormCell(
-              label: '家庭医生服务包',
-              height: 70,
-              content: controller.state.selectedServicePackageName,
-              onTap: () {
-                Scaffold.of(context).openEndDrawer();
-              },
-            ),
-          ),
-          Obx(
-            () => VListFormCell(
-              label: '备注',
-              content: controller.state.notes,
-              onTap: () async {
-                String? result = await VDialogInput(
-                  title: '备注',
-                  initialValue: controller.state.notes,
-                ).show();
-                controller.state.notes = result ?? controller.state.notes;
-              },
-            ),
-          ),
-        ],
+  VSideNavMenuItem _buildPersonalInformation() {
+    return VSideNavMenuItem(
+      title: "个人信息",
+      icon: Icon(Icons.edit_document, color: Colors.grey.shade700),
+      // onTap: () {}
+      route: VRouteSetting(
+        "/personal_information_page",
+        () => const PersonalInformationPage(),
+        binding: BindingsBuilder(
+          () {
+            Get.lazyPut(() => ServicePackageContractController());
+          },
+        ),
       ),
     );
   }
+
+  // VSideNavMenuItem _buildAboutItem() {
+  //   return VSideNavMenuItem(
+  //     title: "人群分类",
+  //     isRequired: true,
+  //     icon: Icon(Icons.info_outline, color: Colors.grey.shade700),
+  //     route: VRouteSetting(
+  //       "/crowd_label_panel",
+  //       () => CrowdLabel(),
+  //       binding: BindingsBuilder(
+  //         () {
+  //           Get.lazyPut(() => CrowdLabelsController());
+  //           Get.lazyPut(() => CreatePatientController());
+  //         },
+  //       ),
+  //     ),
+  //     // route: VRouteSetting("/about", () => const AboutPage()),
+  //   );
+  // }
+
+  // VSideNavMenuItem _buildLogOutItem() {
+  //   return VSideNavMenuItem(
+  //     title: "个人信息",
+  //     icon: Icon(Icons.exit_to_app, color: Colors.grey.shade700),
+  //     route: VRouteSetting(
+  //       "/patient_info_panel",
+  //       () => const PatientInfo(),
+  //       binding: BindingsBuilder(
+  //         () {
+  //           Get.lazyPut(() => CreatePatientController());
+  //         },
+  //       ),
+  //     ),
+  //     // shouldRearrage: true, // TODO: 调整样式后启用
+  //   );
+  // }
 }

+ 37 - 5
lib/pages/contract/package_list/widgets/capture_portrait.dart

@@ -1,17 +1,49 @@
+import 'dart:convert';
+import 'dart:io';
+
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
+import 'package:image_picker/image_picker.dart';
 import 'package:vnoteapp/pages/contract/package_list/controller.dart';
 
 class CapturePortraitPage extends GetView<ServicePackageContractController> {
   const CapturePortraitPage({super.key});
+  Future<String> convertImageToBase64(XFile image) async {
+    List<int> imageBytes = await image.readAsBytes();
+    String base64Image = base64Encode(imageBytes);
+    return base64Image;
+  }
 
   @override
   Widget build(BuildContext context) {
-    return Center(
-      child: Container(
-        width: 300,
-        height: 300,
-        color: Colors.red,
+    return InkWell(
+      onTap: () async {
+        XFile? image =
+            await ImagePicker().pickImage(source: ImageSource.camera);
+        controller.state.userImage = image;
+        String base64Image = await convertImageToBase64(image!);
+        controller.base64Image = base64Image;
+      },
+      child: Center(
+        child: Container(
+          width: 300,
+          height: 300,
+          decoration: BoxDecoration(
+            border: Border.all(
+              color: Colors.grey,
+            ),
+            borderRadius: BorderRadius.circular(8),
+          ),
+          child: controller.state.userImage != null
+              ? Image.file(
+                  File(controller.state.userImage!.path),
+                )
+              : const Icon(
+                  Icons.add,
+                  size: 100,
+                  color: Colors.grey,
+                ),
+        ),
       ),
     );
   }

+ 236 - 4
lib/pages/contract/package_list/widgets/family_doctor_service_package.dart

@@ -1,5 +1,7 @@
+import 'package:fis_jsonrpc/rpc.dart';
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
+import 'package:vnoteapp/components/alert_dialog.dart';
 import 'package:vnoteapp/pages/contract/package_list/controller.dart';
 
 class FamilyDoctorServicePackagePage
@@ -8,11 +10,241 @@ class FamilyDoctorServicePackagePage
 
   @override
   Widget build(BuildContext context) {
-    return Center(
+    return buildServicePackageList(context);
+  }
+
+  Widget buildAlertDialog(ServicePackDTO servicePackDTO) {
+    return VAlertDialog(
+      title: '${servicePackDTO.name}详情',
+      content: Container(
+          height: 200,
+          alignment: Alignment.topLeft,
+          padding: const EdgeInsets.symmetric(horizontal: 15),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              LayoutBuilder(
+                builder: (BuildContext context, BoxConstraints constraints) {
+                  return ConstrainedBox(
+                    constraints: const BoxConstraints(
+                      maxHeight: 100,
+                    ),
+                    child: Scrollbar(
+                      thumbVisibility: true,
+                      child: ListView(
+                        shrinkWrap: true,
+                        children: [
+                          Text(
+                            servicePackDTO.content ?? "",
+                            style: const TextStyle(fontSize: 16),
+                          ),
+                        ],
+                      ),
+                    ),
+                  );
+                },
+              ),
+              const SizedBox(
+                height: 5,
+              ),
+              const Text(
+                '服务项目',
+                style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
+              ),
+              const SizedBox(
+                height: 5,
+              ),
+              Expanded(
+                child: Text(
+                  controller.getServiceItemsName(
+                    servicePackDTO.items ?? [],
+                  ),
+                ),
+              )
+            ],
+          )),
+      // actions: <Widget>[
+      //   TextButton(
+      //     child: const Text('取消'),
+      //     onPressed: () {
+      //       Navigator.of(context).pop(); // 关闭对话框
+      //     },
+      //   ),
+      //   TextButton(
+      //     child: const Text('确定'),
+      //     onPressed: () {
+      //       // 在这里处理确定按钮的逻辑
+      //       Navigator.of(context).pop(); // 关闭对话框
+      //     },
+      //   ),
+      // ],
+    );
+  }
+
+  Widget buildItem(ServicePackDTO servicePackDTO, BuildContext context) {
+    return InkWell(
+      onTap: () {
+        controller.changeServicePackage(servicePackDTO);
+      },
       child: Container(
-        width: 300,
-        height: 300,
-        color: Colors.black,
+        margin: const EdgeInsets.symmetric(
+          vertical: 10,
+          horizontal: 50,
+        ),
+        padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
+        decoration: BoxDecoration(
+          color: Colors.white,
+          borderRadius: const BorderRadius.all(
+            Radius.circular(
+              8,
+            ),
+          ),
+          border: Border.all(
+            color:
+                controller.state.selectedServicePackage.contains(servicePackDTO)
+                    ? Theme.of(context).primaryColor
+                    : Colors.transparent,
+            width: 2,
+          ),
+        ),
+        child: Row(
+          children: [
+            Container(
+              padding: const EdgeInsets.only(right: 20),
+              child: Obx(
+                () => Icon(
+                  Icons.check_circle_outline,
+                  size: 35,
+                  color: controller.state.selectedServicePackage
+                          .contains(servicePackDTO)
+                      ? Theme.of(context).primaryColor
+                      : Colors.grey.shade500,
+                ),
+              ),
+            ),
+            Expanded(
+              child: Column(
+                children: [
+                  Row(
+                    children: [
+                      Expanded(
+                        child: Text(
+                          servicePackDTO.name ?? '',
+                          style: const TextStyle(
+                            fontSize: 25,
+                            fontWeight: FontWeight.bold,
+                          ),
+                        ),
+                      ),
+                      Expanded(
+                        child: Row(
+                          children: [
+                            const Text(
+                              '服务人群:',
+                              style: TextStyle(fontSize: 20),
+                            ),
+                            Text(
+                              controller.setNormalLabels(
+                                servicePackDTO.labels ?? [],
+                              ),
+                              style: const TextStyle(fontSize: 18),
+                            ),
+                          ],
+                        ),
+                      ),
+                    ],
+                  ),
+                  const SizedBox(
+                    height: 10,
+                  ),
+                  Row(
+                    mainAxisSize: MainAxisSize.max,
+                    children: [
+                      Expanded(
+                        child: Row(
+                          mainAxisSize: MainAxisSize.max,
+                          children: [
+                            const Text(
+                              '服务包介绍:',
+                              style: TextStyle(fontSize: 18),
+                            ),
+                            Expanded(
+                              child: Container(
+                                alignment: Alignment.centerLeft,
+                                height: 50,
+                                child: Text(
+                                  servicePackDTO.content ?? '',
+                                  overflow: TextOverflow.ellipsis,
+                                  maxLines: 2,
+                                  style: const TextStyle(fontSize: 18),
+                                ),
+                              ),
+                            ),
+                          ],
+                        ),
+                      ),
+                    ],
+                  ),
+                ],
+              ),
+            ),
+            Container(
+              padding: const EdgeInsets.only(left: 20),
+              child: TextButton(
+                child: const Text(
+                  '查看',
+                  style: TextStyle(fontSize: 18),
+                ),
+                onPressed: () async {
+                  // await Get.toNamed(
+                  //   '/contract/package_info',
+                  //   parameters: {
+                  //     "servicePack": json.encode(servicePackDTO.toJson()),
+                  //   },
+                  // );
+                  showDialog(
+                    context: context,
+                    builder: (BuildContext context) {
+                      return buildAlertDialog(servicePackDTO);
+                    },
+                  );
+                },
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget buildServicePackageList(BuildContext context) {
+    return Obx(
+      () => Expanded(
+        child: controller.state.servicePackageItems.isEmpty
+            ? Container(
+                margin: const EdgeInsets.only(top: 80),
+                child: Column(
+                  children: [
+                    Center(
+                      child: Image.asset(
+                        "assets/images/no_data.png",
+                        width: 300,
+                        height: 300,
+                        fit: BoxFit.cover,
+                      ),
+                    ),
+                    const Text(
+                      "暂无数据,先看看别的吧",
+                      style: TextStyle(fontSize: 18),
+                    ),
+                  ],
+                ),
+              )
+            : ListView(
+                children: controller.state.servicePackageItems
+                    .map((ServicePackDTO e) => buildItem(e, context))
+                    .toList(),
+              ),
       ),
     );
   }

+ 47 - 57
lib/pages/contract/package_list/widgets/service_information.dart

@@ -4,7 +4,7 @@
  * @Author: guanxiaoxin
  * @Date: 2023-10-13 10:54:37
  * @LastEditors: guanxiaoxin
- * @LastEditTime: 2023-10-16 10:43:37
+ * @LastEditTime: 2023-10-18 17:20:37
  * @FilePath: \VNoteApp\lib\pages\contract\package_list\widgets\service_information.dart
  */
 import 'package:flutter/material.dart';
@@ -22,65 +22,55 @@ class ServiceInformationPage extends GetView<ServicePackageContractController> {
 
   @override
   Widget build(BuildContext context) {
-    return Container(
-      padding: const EdgeInsets.all(16),
-      decoration: const BoxDecoration(
-        color: Color.fromRGBO(238, 238, 238, 1),
-        border: Border(
-          left: BorderSide(color: Colors.white),
-        ),
-      ),
-      child: ListView(
-        children: [
-          VPanel(
-            child: VListFormCellGroup(
-              children: [
-                VListFormCell(
-                  label: '签约医生',
-                  content: Store.user.principalName,
-                ),
-                VListFormCell(
-                  label: '医生电话',
-                  content: Store.user.principalPhone,
+    return Column(
+      children: [
+        VPanel(
+          child: VListFormCellGroup(
+            children: [
+              VListFormCell(
+                label: '签约医生',
+                content: Store.user.principalName,
+              ),
+              VListFormCell(
+                label: '医生电话',
+                content: Store.user.principalPhone,
+              ),
+              Obx(
+                () => VListFormCell(
+                  label: '服务日期',
+                  content: DateFormat('yyyy-MM-dd')
+                      .format(controller.state.serviceStartDate),
+                  onTap: () async {
+                    final result = await VDialogDate(
+                      title: '服务日期',
+                      initialValue: controller.state.serviceStartDate,
+                    ).show();
+                    controller.state.serviceStartDate = result;
+                  },
                 ),
-                Obx(
-                  () => VListFormCell(
-                    label: '服务日期',
-                    content: DateFormat('yyyy-MM-dd')
-                        .format(controller.state.serviceStartDate),
-                    onTap: () async {
-                      final result = await VDialogDate(
-                        title: '服务日期',
-                        initialValue: controller.state.serviceStartDate,
-                      ).show();
-                      controller.state.serviceStartDate = result;
-                    },
-                  ),
+              ),
+              Obx(
+                () => VListFormCell(
+                  label: '服务年限',
+                  content: '${controller.state.serviceTime}年',
+                  onTap: () async {
+                    String? result = await VDialogSelect<VSelectModel, String>(
+                      source: [
+                        VSelectModel(code: "1", name: "1年"),
+                        VSelectModel(code: "2", name: "2年"),
+                        VSelectModel(code: "3", name: "3年"),
+                      ],
+                      labelGetter: (data) => data.name,
+                      valueGetter: (data) => data.code,
+                    ).show();
+                    controller.state.serviceTime = int.parse(result ?? '1');
+                  },
                 ),
-                Obx(
-                  () => VListFormCell(
-                    label: '服务年限',
-                    content: '${controller.state.serviceTime}年',
-                    onTap: () async {
-                      String? result =
-                          await VDialogSelect<VSelectModel, String>(
-                        source: [
-                          VSelectModel(code: "1", name: "1年"),
-                          VSelectModel(code: "2", name: "2年"),
-                          VSelectModel(code: "3", name: "3年"),
-                        ],
-                        labelGetter: (data) => data.name,
-                        valueGetter: (data) => data.code,
-                      ).show();
-                      controller.state.serviceTime = int.parse(result ?? '1');
-                    },
-                  ),
-                ),
-              ],
-            ),
+              ),
+            ],
           ),
-        ],
-      ),
+        ),
+      ],
     );
   }
 }

+ 17 - 20
lib/pages/patient/create/view.dart

@@ -1,11 +1,11 @@
 import 'package:flutter/material.dart';
 import 'package:get/get.dart';
 import 'package:vnoteapp/components/button.dart';
+import 'package:vnoteapp/components/floating_window/floating_window.dart';
 import 'package:vnoteapp/components/side_nav/defines.dart';
 import 'package:vnoteapp/components/side_nav/side_nav.dart';
 import 'package:vnoteapp/pages/controllers/crowd_labels.dart';
 import 'package:vnoteapp/pages/patient/create/controller.dart';
-import 'package:vnoteapp/pages/patient/create/widgets/address.dart';
 import 'package:vnoteapp/pages/patient/create/widgets/area.dart';
 import 'package:vnoteapp/pages/patient/create/widgets/crowd_label.dart';
 import 'package:vnoteapp/pages/patient/create/widgets/patient_info.dart';
@@ -45,6 +45,7 @@ class CreatePatientPage extends GetView<CreatePatientController> {
             ],
           ),
         ),
+        const FloatingWindow(),
       ],
     );
   }
@@ -54,13 +55,12 @@ class CreatePatientPage extends GetView<CreatePatientController> {
     items.add(_buildSignatureItem());
     items.add(_buildAboutItem());
     items.add(_buildLogOutItem());
-    items.add(_buildLogOutItsem());
     return items;
   }
 
   VSideNavMenuItem _buildSignatureItem() {
     return VSideNavMenuItem(
-      title: "签信息",
+      title: "签信息",
       icon: Icon(Icons.edit_document, color: Colors.grey.shade700),
       // onTap: () {}
       route: VRouteSetting(
@@ -94,6 +94,20 @@ class CreatePatientPage extends GetView<CreatePatientController> {
     );
   }
 
+  // Floating floating = Floating(
+  //   GestureDetector(
+  //     onTap: () {
+  //       print('You tapped the floating window!');
+  //     },
+  //     child: Container(
+  //       width: 100,
+  //       height: 100,
+  //       color: Colors.red,
+  //     ),
+  //   ),
+  //   slideType: FloatingSlideType.onRightAndBottom,
+  //   isShowLog: true,
+  // );
   VSideNavMenuItem _buildLogOutItem() {
     return VSideNavMenuItem(
       title: "个人信息",
@@ -110,21 +124,4 @@ class CreatePatientPage extends GetView<CreatePatientController> {
       // shouldRearrage: true, // TODO: 调整样式后启用
     );
   }
-
-  VSideNavMenuItem _buildLogOutItsem() {
-    return VSideNavMenuItem(
-      title: "户籍信息",
-      icon: Icon(Icons.exit_to_app, color: Colors.grey.shade700),
-      // shouldRearrage: true, // TODO: 调整样式后启用
-      route: VRouteSetting(
-        "/address",
-        () => const Address(),
-        binding: BindingsBuilder(
-          () {
-            Get.lazyPut(() => CreatePatientController());
-          },
-        ),
-      ),
-    );
-  }
 }

+ 46 - 0
lib/pages/patient/create/widgets/patient_info.dart

@@ -174,6 +174,52 @@ class PatientInfo extends GetView<CreatePatientController> {
                 },
               ),
             ),
+            VListFormCell(
+              label: "同步户籍地址到现住地址",
+              labelWidth: 250,
+              contentWidget: Container(
+                child: Obx(
+                  () => Switch(
+                    onChanged: (value) {
+                      controller.onSyncAddressCheckChanged(value);
+                    },
+                    value: controller.state.isSyncAddresses,
+                  ),
+                ),
+              ),
+            ),
+            Obx(
+              () => VListFormCell(
+                label: "户籍地址",
+                content: controller.state.censusRegister,
+                onTap: () async {
+                  final result = await VDialogInput(
+                    title: "户籍地址",
+                    initialValue: controller.state.censusRegister,
+                    placeholder: "请填写户籍地址",
+                  ).show();
+                  if (result != null) {
+                    controller.onCensusRegisterChanged(result);
+                  }
+                },
+              ),
+            ),
+            Obx(
+              () => VListFormCell(
+                label: "现住地址",
+                content: controller.state.address,
+                onTap: () async {
+                  final result = await VDialogInput(
+                    title: "现住地址",
+                    initialValue: controller.state.address,
+                    placeholder: "请填写现住地址",
+                  ).show();
+                  if (result != null) {
+                    controller.state.address = result;
+                  }
+                },
+              ),
+            ),
           ],
         ),
       ],

+ 3 - 3
lib/routes/nav_ids.dart

@@ -5,12 +5,12 @@ abstract class NavIds {
   /// 主页
   static const HOME = 1001;
 
-  /// 设置
-  static const SETTINGS = 1002;
-
   /// 签约
   static const CONTRACT = 1002;
 
   /// 医生签约 - 创建病人
   static const CREATE = 1003;
+
+  /// 设置
+  static const SETTINGS = 1004;
 }

+ 1 - 1
lib/routes/routes.dart

@@ -120,7 +120,7 @@ class Routes {
       "/patient/create",
       // participatesInRootNavigator: false,
       // preventDuplicates: true,
-      () => const CreatePatientPage(),
+      () => CreatePatientPage(),
       binding: BindingsBuilder(
         () {
           Get.lazyPut(() => CrowdLabelsController());