diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 39e1db6..da6d9fe 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -94,30 +94,36 @@ class _TrendPageState extends ConsumerState { final data = >[]; final rng = Random(42); + // 7天模式全部有数据;30天/90天模式随机约70%的天数有数据 + final hasDataChance = _period <= 7 ? 1.0 : 0.7; + for (int i = _period - 1; i >= 0; i--) { final date = now.subtract(Duration(days: i)); - num value; + num? value; num? value2; - switch (widget.metricType) { - case 'blood_pressure': - value = 110 + rng.nextInt(40); - value2 = 70 + rng.nextInt(25); - break; - case 'heart_rate': - value = 65 + rng.nextInt(35); - break; - case 'glucose': - value = 4.0 + rng.nextDouble() * 3.0; - break; - case 'spo2': - value = 95 + rng.nextDouble() * 5; - break; - case 'weight': - value = 58 + rng.nextDouble() * 15; - break; - default: - value = 100 + rng.nextInt(50); + final measured = rng.nextDouble() < hasDataChance; + if (measured) { + switch (widget.metricType) { + case 'blood_pressure': + value = 110 + rng.nextInt(40); + value2 = 70 + rng.nextInt(25); + break; + case 'heart_rate': + value = 65 + rng.nextInt(35); + break; + case 'glucose': + value = 4.0 + rng.nextDouble() * 3.0; + break; + case 'spo2': + value = 95 + rng.nextDouble() * 5; + break; + case 'weight': + value = 58 + rng.nextDouble() * 15; + break; + default: + value = 100 + rng.nextInt(50); + } } data.add({ @@ -166,14 +172,18 @@ class _TrendPageState extends ConsumerState { ({double min, double max, double step}) _calcYRange() { if (_data.isEmpty) return (min: 0, max: 100, step: 20); + final validData = _data.where((e) => e['value'] != null).toList(); + if (validData.isEmpty) return (min: 0, max: 100, step: 20); + num allMin, allMax; if (_isDualLine) { - final values1 = _data.map((e) => e['value'] as num); - final values2 = _data.map((e) => e['value2'] as num); - allMin = min(values1.reduce(min), values2.reduce(min)); - allMax = max(values1.reduce(max), values2.reduce(max)); + final values1 = validData.map((e) => e['value'] as num); + final validDual = validData.where((e) => e.containsKey('value2') && e['value2'] != null).toList(); + final values2 = validDual.map((e) => e['value2'] as num); + allMin = min(values1.reduce(min), values2.isEmpty ? values1.reduce(min) : values2.reduce(min)); + allMax = max(values1.reduce(max), values2.isEmpty ? values1.reduce(max) : values2.reduce(max)); } else { - final values = _data.map((e) => e['value'] as num); + final values = validData.map((e) => e['value'] as num); allMin = values.reduce(min); allMax = values.reduce(max); } @@ -210,8 +220,9 @@ class _TrendPageState extends ConsumerState { } Map _calcStats() { - if (_data.isEmpty) return {}; - final values = _data.map((e) => e['value'] as num).toList(); + final validData = _data.where((e) => e['value'] != null).toList(); + if (validData.isEmpty) return {}; + final values = validData.map((e) => e['value'] as num).toList(); final maxVal = values.reduce(max); final minVal = values.reduce(min); @@ -219,7 +230,16 @@ class _TrendPageState extends ConsumerState { String avgStr; if (_isDualLine) { - final values2 = _data.map((e) => e['value2'] as num).toList(); + final validDual = validData.where((e) => e.containsKey('value2') && e['value2'] != null).toList(); + if (validDual.isEmpty) { + return { + 'max': _formatValue(maxVal), + 'min': _formatValue(minVal), + 'avg': _formatValue(avgVal), + 'count': validData.length, + }; + } + final values2 = validDual.map((e) => e['value2'] as num).toList(); final maxVal2 = values2.reduce(max); final minVal2 = values2.reduce(min); final avgVal2 = values2.reduce((a, b) => a + b) / values2.length; @@ -228,7 +248,7 @@ class _TrendPageState extends ConsumerState { 'max': '${_formatValue(maxVal)} / ${_formatValue(maxVal2)}', 'min': '${_formatValue(minVal)} / ${_formatValue(minVal2)}', 'avg': avgStr, - 'count': _data.length, + 'count': validData.length, }; } @@ -236,7 +256,7 @@ class _TrendPageState extends ConsumerState { 'max': _formatValue(maxVal), 'min': _formatValue(minVal), 'avg': _formatValue(avgVal), - 'count': _data.length, + 'count': validData.length, }; } @@ -309,7 +329,9 @@ class _TrendPageState extends ConsumerState { // ==================== 当前值卡片 ==================== List _buildCurrentValueCard() { if (_data.isEmpty) return []; - final latest = _data.last; + // 找到最新的一条有数据的记录 + final latest = _data.reversed.where((e) => e['value'] != null).firstOrNull; + if (latest == null) return []; final val = latest['value']; final val2 = latest['value2']; final status = _getStatus(val as num, value2: val2 as num?); @@ -491,7 +513,8 @@ class _TrendPageState extends ConsumerState { Widget _buildRecordList() { if (_data.isEmpty) return const SizedBox.shrink(); - final displayList = _showAllRecords ? _data : _data.reversed.take(5).toList().reversed.toList(); + final validRecords = _data.where((e) => e['value'] != null).toList().reversed.toList(); + final displayList = _showAllRecords ? validRecords : validRecords.take(5).toList(); return Container( width: double.infinity, @@ -515,12 +538,12 @@ class _TrendPageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('数据记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - Text('${_data.length} 条', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), + Text('${validRecords.length} 条', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), ], ), ), ...displayList.map((item) => _buildRecordRow(item)), - if (_data.length > 5) + if (validRecords.length > 5) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Center( @@ -530,7 +553,7 @@ class _TrendPageState extends ConsumerState { shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), ), child: Text( - _showAllRecords ? '收起' : '查看全部 (${_data.length} 条)', + _showAllRecords ? '收起' : '查看全部 (${validRecords.length} 条)', style: const TextStyle(fontSize: 13, color: Color(0xFF635BFF)), ), ), @@ -587,10 +610,11 @@ class _TrendPageState extends ConsumerState { const rightPadding = 8.0; final chartW = renderBox.size.width - leftPadding - rightPadding; - // 找到 x 方向最近的数据点 - int nearestIndex = 0; + // 找到 x 方向最近的有数据点 + int? nearestIndex; double minDist = double.infinity; for (int i = 0; i < _data.length; i++) { + if (_data[i]['value'] == null) continue; final pointX = leftPadding + (chartW * i / (_data.length - 1)); final dist = (localPosition.dx - pointX).abs(); if (dist < minDist) { @@ -599,8 +623,8 @@ class _TrendPageState extends ConsumerState { } } - // 点击偏离数据点太远则不响应 - if (minDist > 40) return; + // 点击偏离数据点太远或没有有效数据点则不响应 + if (nearestIndex == null || minDist > 40) return; final item = _data[nearestIndex]; final date = item['date'] as DateTime; @@ -730,6 +754,12 @@ class _StatItem extends StatelessWidget { // 趋势图 CustomPainter // ============================================================ +class _Segment { + final int start; + final int end; + const _Segment({required this.start, required this.end}); +} + class _TrendChartPainter extends CustomPainter { final List> data; final String metricType; @@ -774,36 +804,92 @@ class _TrendChartPainter extends CustomPainter { // ---- 1. 网格线 & Y轴刻度 ---- _drawGridAndYAxis(canvas, plotLeft, plotTop, plotRight, plotBottom, chartH); - // ---- 2. 数据点坐标计算 ---- + // ---- 2. 数据点坐标计算(跳过 null 值)---- final points1 = []; final points2 = []; + // 记录每条线的分段信息:每个段是连续非null的点的索引范围 + final segments1 = <_Segment>[]; + final segments2 = <_Segment>[]; + + int? segStart1; + int? segStart2; + for (int i = 0; i < data.length; i++) { final x = plotLeft + (chartW * i / (data.length - 1)); - final val = (data[i]['value'] as num).toDouble(); - final y = plotTop + chartH - ((val - yMin) / (yMax - yMin)) * chartH; - points1.add(Offset(x, y.clamp(plotTop, plotBottom))); + final val = data[i]['value'] as num?; + + if (val != null) { + final y = plotTop + chartH - ((val.toDouble() - yMin) / (yMax - yMin)) * chartH; + points1.add(Offset(x, y.clamp(plotTop, plotBottom))); + segStart1 ??= points1.length - 1; + } else { + if (segStart1 != null && points1.length > segStart1 + 1) { + segments1.add(_Segment(start: segStart1, end: points1.length - 1)); + } + segStart1 = null; + } if (isDualLine && data[i].containsKey('value2')) { - final val2 = (data[i]['value2'] as num).toDouble(); - final y2 = plotTop + chartH - ((val2 - yMin) / (yMax - yMin)) * chartH; - points2.add(Offset(x, y2.clamp(plotTop, plotBottom))); + final val2 = data[i]['value2'] as num?; + if (val2 != null) { + final y2 = plotTop + chartH - ((val2.toDouble() - yMin) / (yMax - yMin)) * chartH; + points2.add(Offset(x, y2.clamp(plotTop, plotBottom))); + segStart2 ??= points2.length - 1; + } else { + if (segStart2 != null && points2.length > segStart2 + 1) { + segments2.add(_Segment(start: segStart2, end: points2.length - 1)); + } + segStart2 = null; + } } } - // ---- 3. 填充区域(主线) ---- - _drawFill(canvas, points1, plotBottom, _primaryColor); - - // ---- 4. 填充区域(副线,血压) ---- - if (isDualLine && points2.isNotEmpty) { - _drawFill(canvas, points2, plotBottom, _secondaryColor); + // 收尾最后一个段 + if (segStart1 != null && points1.length > segStart1 + 1) { + segments1.add(_Segment(start: segStart1, end: points1.length - 1)); + } + if (segStart2 != null && points2.length > segStart2 + 1) { + segments2.add(_Segment(start: segStart2, end: points2.length - 1)); } - // ---- 5. 数据线(主线) ---- - _drawSmoothLine(canvas, points1, _primaryColor); + // ---- 3. 填充区域(主线,分段绘制)---- + if (segments1.isNotEmpty) { + for (final seg in segments1) { + _drawFill(canvas, points1.sublist(seg.start, seg.end + 1), plotBottom, _primaryColor); + } + } else if (points1.length >= 2) { + _drawFill(canvas, points1, plotBottom, _primaryColor); + } - // ---- 6. 数据线(副线) ---- - if (isDualLine && points2.isNotEmpty) { - _drawSmoothLine(canvas, points2, _secondaryColor); + // ---- 4. 填充区域(副线,血压,分段绘制)---- + if (isDualLine) { + if (segments2.isNotEmpty) { + for (final seg in segments2) { + _drawFill(canvas, points2.sublist(seg.start, seg.end + 1), plotBottom, _secondaryColor); + } + } else if (points2.length >= 2) { + _drawFill(canvas, points2, plotBottom, _secondaryColor); + } + } + + // ---- 5. 数据线(主线,分段绘制)---- + if (segments1.isNotEmpty) { + for (final seg in segments1) { + _drawLine(canvas, points1.sublist(seg.start, seg.end + 1), _primaryColor); + } + } else if (points1.length >= 2) { + _drawLine(canvas, points1, _primaryColor); + } + + // ---- 6. 数据线(副线,分段绘制)---- + if (isDualLine) { + if (segments2.isNotEmpty) { + for (final seg in segments2) { + _drawLine(canvas, points2.sublist(seg.start, seg.end + 1), _secondaryColor); + } + } else if (points2.length >= 2) { + _drawLine(canvas, points2, _secondaryColor); + } } // ---- 7. 数据点 ---- @@ -864,16 +950,14 @@ class _TrendChartPainter extends CustomPainter { ..moveTo(points.first.dx, plotBottom) ..lineTo(points.first.dx, points.first.dy); for (int i = 1; i < points.length; i++) { - final midX = (points[i - 1].dx + points[i].dx) / 2; - fillPath.quadraticBezierTo(midX, points[i - 1].dy, midX, (points[i - 1].dy + points[i].dy) / 2); + fillPath.lineTo(points[i].dx, points[i].dy); } - fillPath.lineTo(points.last.dx, points.last.dy); fillPath.lineTo(points.last.dx, plotBottom); fillPath.close(); canvas.drawPath(fillPath, fillPaint); } - void _drawSmoothLine(Canvas canvas, List points, Color color) { + void _drawLine(Canvas canvas, List points, Color color) { if (points.length < 2) return; final paint = Paint() ..color = color @@ -882,12 +966,10 @@ class _TrendChartPainter extends CustomPainter { ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; - final path = Path()..moveTo(points[0].dx, points[0].dy); + final path = Path()..moveTo(points.first.dx, points.first.dy); for (int i = 1; i < points.length; i++) { - final cpx = (points[i - 1].dx + points[i].dx) / 2; - path.quadraticBezierTo(cpx, points[i - 1].dy, (cpx + points[i].dx) / 2, (points[i - 1].dy + points[i].dy) / 2); + path.lineTo(points[i].dx, points[i].dy); } - path.lineTo(points.last.dx, points.last.dy); canvas.drawPath(path, paint); } diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index cdc5b19..c98c30e 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -20,17 +20,23 @@ class _HomePageState extends ConsumerState { bool _taskCardsExpanded = true; double _lastScrollOffset = 0; DateTime? _lastCollapseTime; - bool _medicationTaken = false; bool _exerciseDone = false; + final Set _welcomedAgents = {}; static final _mockFollowUps = [ {'hospital': '协和医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 2)), 'type': '复查'}, {'hospital': '人民医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 3)), 'type': '复诊'}, ]; - @override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); } + @override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); _textCtrl.addListener(_onTextChange); } @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } + void _onTextChange() { + if (_textCtrl.text.isNotEmpty && _taskCardsExpanded) { + setState(() => _taskCardsExpanded = false); + } + } + void _onScroll() { if (!_scrollCtrl.hasClients) return; final offset = _scrollCtrl.offset; @@ -51,6 +57,7 @@ class _HomePageState extends ConsumerState { final text = _textCtrl.text.trim(); if (text.isEmpty) return; _textCtrl.clear(); + setState(() => _taskCardsExpanded = false); ref.read(chatProvider.notifier).sendMessage(text); } @@ -64,7 +71,6 @@ class _HomePageState extends ConsumerState { drawer: const HealthDrawer(), backgroundColor: const Color(0xFFF8F7FF), body: SafeArea( - bottom: false, child: Column(children: [ // ── 顶部栏 ── _buildHeader(user), @@ -75,14 +81,8 @@ class _HomePageState extends ConsumerState { // ── 聊天区域(弹性填充剩余空间) ── Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), - // ── 智能体选择器(常驻显示) ── - _buildAgentBar(selectedAgent), - - // ── 选中智能体的操作面板 ── - if (selectedAgent != null) _buildAgentPanel(context, selectedAgent), - - // ── 输入框 ── - _buildInputBar(context), + // ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ── + _buildBottomBar(context, selectedAgent), ]), ), ); @@ -134,18 +134,15 @@ class _HomePageState extends ConsumerState { return GestureDetector( onTap: () => setState(() => _taskCardsExpanded = true), behavior: HitTestBehavior.opaque, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]), - child: Row(children: [ - Icon(Icons.assignment_turned_in_outlined, size: 18, color: const Color(0xFF635BFF)), - const SizedBox(width: 8), - const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const Spacer(), - Text('点击展开', style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF))), - Icon(Icons.keyboard_arrow_right, size: 18, color: const Color(0xFF635BFF)), - ]), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Text('今日任务', style: TextStyle(fontSize: 13, color: Color(0xFF635BFF))), + const SizedBox(width: 4), + const Text('▾', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF))), + ]), + ), ), ); } @@ -160,8 +157,8 @@ class _HomePageState extends ConsumerState { const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text('收起', style: TextStyle(fontSize: 12, color: const Color(0xFF999999))), - Icon(Icons.keyboard_arrow_up, size: 18, color: const Color(0xFF999999)), + const Text('收起', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + const Text('∧', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), ])), ]), const SizedBox(height: 10), @@ -355,7 +352,6 @@ class _HomePageState extends ConsumerState { void _handleMedicationCheck() async { await ref.read(medicationServiceProvider).confirm(''); - setState(() => _medicationTaken = true); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF))); } @@ -373,7 +369,7 @@ class _HomePageState extends ConsumerState { Widget _buildAgentBar(ActiveAgent? selected) { return Container( - height: 44, + height: 36, padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( scrollDirection: Axis.horizontal, @@ -385,21 +381,27 @@ class _HomePageState extends ConsumerState { return GestureDetector( onTap: () { final notifier = ref.read(selectedAgentProvider.notifier); - notifier.select(isActive ? null : agent); - // 切换智能体时清空聊天 - if (!isActive) ref.read(chatProvider.notifier).setAgent(agent); + final newAgent = isActive ? null : agent; + notifier.select(newAgent); + if (newAgent != null) { + ref.read(chatProvider.notifier).setAgent(newAgent); + if (!_welcomedAgents.contains(newAgent)) { + _welcomedAgents.add(newAgent); + ref.read(chatProvider.notifier).insertAgentWelcome(newAgent); + } + } }, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: isActive ? const Color(0xFF635BFF) : Colors.white, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), border: Border.all(color: isActive ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: isActive ? Colors.white : const Color(0xFF666666)), - const SizedBox(width: 4), - Text(label, style: TextStyle(fontSize: 12, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))), + Icon(icon, size: 13, color: isActive ? Colors.white : const Color(0xFF666666)), + const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 11, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))), ]), ), ); @@ -408,27 +410,52 @@ class _HomePageState extends ConsumerState { ); } - // ═════════════════════ 智能体操作面板(选中后显示) ═════════════════════ + // ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════ - Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) { + Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent) { + return Column(mainAxisSize: MainAxisSize.min, children: [ + // 智能体胶囊栏(常驻,高度36) + _buildAgentBar(selectedAgent), + + // 选中智能体的操作面板(紧凑版) + if (selectedAgent != null) _buildCompactAgentPanel(selectedAgent), + + // 输入框(紧凑) + _buildCompactInputBar(context), + ]); + } + + Widget _buildCompactAgentPanel(ActiveAgent agent) { final titles = {ActiveAgent.consultation: 'AI 问诊', ActiveAgent.health: '记数据', ActiveAgent.diet: '拍饮食', ActiveAgent.medication: '药管家', ActiveAgent.report: '看报告', ActiveAgent.exercise: '运动计划'}; final tips = {ActiveAgent.consultation: '或直接对我说你的症状', ActiveAgent.health: '或直接对我说:"血压 135/85"', ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"', ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"', ActiveAgent.report: '或直接上传报告图片', ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"'}; return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))), child: Column(mainAxisSize: MainAxisSize.min, children: [ Row(children: [ - Text(titles[agent] ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + Text(titles[agent] ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 6), - Expanded(child: Text(tips[agent] ?? '', style: TextStyle(fontSize: 11, color: Colors.grey[500]))), - GestureDetector(onTap: () => ref.read(selectedAgentProvider.notifier).select(null), child: Icon(Icons.close, size: 18, color: Colors.grey[400])), + Expanded(child: Text(tips[agent] ?? '', style: TextStyle(fontSize: 10, color: Colors.grey[500]))), + GestureDetector(onTap: () => ref.read(selectedAgentProvider.notifier).select(null), child: Icon(Icons.close, size: 16, color: Colors.grey[400])), ]), - const SizedBox(height: 8), + const SizedBox(height: 4), SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: _getAgentButtons(agent))), ])); } + Widget _buildCompactInputBar(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))), + child: Row(children: [ + IconButton(icon: const Icon(Icons.attach_file, size: 20, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context), padding: const EdgeInsets.all(4)), + Expanded(child: TextField(controller: _textCtrl, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 8), border: InputBorder.none, isDense: true, hintStyle: const TextStyle(fontSize: 13)), onSubmitted: (_) => _sendMessage())), + IconButton(icon: const Icon(Icons.send, size: 20, color: Color(0xFF635BFF)), onPressed: _sendMessage, padding: const EdgeInsets.all(4)), + ]), + ); + } + List _getAgentButtons(ActiveAgent agent) { switch (agent) { case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)]; @@ -480,15 +507,4 @@ class _HomePageState extends ConsumerState { ]))); } - Widget _buildInputBar(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))), - child: Row(children: [ - IconButton(icon: const Icon(Icons.attach_file, size: 22, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)), - Expanded(child: TextField(controller: _textCtrl, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: InputBorder.none, isDense: true), onSubmitted: (_) => _sendMessage())), - IconButton(icon: const Icon(Icons.send, size: 22, color: Color(0xFF635BFF)), onPressed: _sendMessage), - ]), - ); - } } diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 22e65bb..af1c40c 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/navigation_provider.dart'; class MedicationEditPage extends ConsumerStatefulWidget { final String? medicationId; @@ -24,7 +25,7 @@ class _MedicationEditPageState extends ConsumerState { appBar: AppBar( backgroundColor: Colors.white, elevation: 0, - leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), + leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)), title: const Text('编辑用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), centerTitle: true, actions: [ diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 150f917..013ba69 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/navigation_provider.dart'; import '../providers/data_providers.dart'; /// 饮食记录列表 @@ -584,8 +585,91 @@ class StaticTextPage extends ConsumerWidget { final String type; const StaticTextPage({super.key, required this.type}); @override Widget build(BuildContext context, WidgetRef ref) { - final titles = {'privacy': '隐私政策', 'terms': '服务协议', 'about': '关于'}; - return Scaffold(appBar: AppBar(title: Text(titles[type] ?? '')), body: const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('内容后期填充', style: TextStyle(color: Color(0xFF999999)))))); + final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于健康管家'}; + final contents = { + 'privacy': '''## 隐私政策 + +**更新日期:2026年1月1日** + +### 一、信息收集 +我们收集以下类型的信息: +- **账户信息**:手机号、昵称、头像(您主动提供) +- **健康数据**:血压、心率、血糖、血氧、体重等健康指标记录 +- **用药信息**:药品名称、剂量、服药时间等用药计划数据 +- **饮食记录**:通过拍照或手动录入的饮食数据 +- **设备信息**:设备型号、操作系统版本(用于适配优化) +- **日志信息**:App 使用情况、崩溃报告 + +### 二、信息使用 +我们使用您的信息用于以下目的: +- 提供和改进健康管理服务 +- AI 健康分析和个性化建议 +- 用药提醒和复查通知推送 +- App 功能优化和问题修复 + +### 三、信息保护 +- 所有健康数据均采用 HTTPS 加密传输 +- 数据存储于安全服务器,采用行业标准的加密措施 +- 我们不会向任何第三方出售、出租或共享您的个人健康数据 +- 医生仅可查看其签约患者的数据,且需经过您的授权 + +### 四、信息保留 +- 对话记录保留 30 天后自动删除 +- 您可以随时删除自己的健康数据和对话记录 +- 账号注销后,所有数据将在 7 天内永久删除 + +### 五、您的权利 +- 查看和导出您的个人数据 +- 修改不准确的个人信息 +- 删除不需要的数据 +- 注销账号并清除所有数据 +- 关闭推送通知 + +### 六、联系我们 +如有任何关于隐私的问题,请联系: +邮箱:privacy@healthbutler.com +电话:400-xxx-xxxx''', + 'about': '''## 关于健康管家 + +**版本**:v1.0.0 (Build 20260101) + +### 产品介绍 +健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。 + +### 核心功能 +- **AI 智能问诊**:基于大语言模型的健康咨询服务 +- **健康数据管理**:血压、心率、血糖、血氧、体重的记录与趋势分析 +- **智能用药管理**:AI 解析处方,自动生成用药计划和提醒 +- **饮食识别分析**:拍照即可识别食物种类、估算热量营养素 +- **报告智能解读**:上传检查报告,AI 自动提取指标并预解读 +- **运动计划管理**:制定和追踪每日运动目标 +- **在线医生问诊**:与签约医生进行远程咨询 + +### 开发团队 +由专业医疗团队与 AI 技术团队联合打造。 + +### 技术支持 +如遇到问题或有建议,请通过以下方式联系我们: +- 在线客服:App 内「设置」→「意见反馈」 +- 客服热线:400-xxx-xxxx(工作日 9:00-18:00) + +### 版权声明 +© 2025-2026 健康管家团队。保留所有权利。 +本软件受中华人民共和国著作权法保护。''', + }; + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)), + title: Text(titles[type] ?? '', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), + centerTitle: true, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Text(contents[type] ?? '内容加载中...', style: const TextStyle(fontSize: 14, height: 1.8, color: Color(0xFF333333))), + ), + ); } } diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index e8f84d9..ce41622 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -145,6 +145,17 @@ class ChatNotifier extends Notifier { state = state.activeAgent == a ? const ChatState() : ChatState(activeAgent: a); } + void insertAgentWelcome(ActiveAgent agent) { + state = state.copyWith(messages: [...state.messages, ChatMessage( + id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}', + role: 'assistant', + content: '', + createdAt: DateTime.now(), + type: MessageType.agentWelcome, + metadata: {'agent': agent.name}, + )]); + } + Future sendMessage(String text) async { if (text.trim().isEmpty || state.isStreaming) return; diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 9dadbe0..51d2469 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -51,19 +51,33 @@ class HealthDrawer extends ConsumerWidget { child: Text('健康概览', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), ), latestHealth.when( - data: (data) => Column(children: [ - _HealthMetric(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), - _HealthMetric(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], '次/分'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), - _HealthMetric(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], 'mmol/L'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), - _HealthMetric(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), - ]), + data: (data) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _HealthMetricChip(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), + _HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), + _HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), + _HealthMetricChip(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), + ], + ), + ), loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))), - error: (_, _) => Column(children: [ - _HealthMetric(icon: Icons.favorite, label: '血压', value: '--'), - _HealthMetric(icon: Icons.monitor_heart, label: '心率', value: '--'), - _HealthMetric(icon: Icons.bloodtype, label: '血糖', value: '--'), - _HealthMetric(icon: Icons.air, label: '血氧', value: '--'), - ]), + error: (_, _) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + const _HealthMetricChip(icon: Icons.favorite, label: '血压', value: '--'), + const _HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: '--'), + const _HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: '--'), + const _HealthMetricChip(icon: Icons.air, label: '血氧', value: '--'), + ], + ), + ), ), const Divider(), @@ -136,15 +150,34 @@ class _DrawerItem extends StatelessWidget { @override Widget build(BuildContext context) => ListTile(leading: Icon(icon, size: 20, color: const Color(0xFF666666)), title: Text(label, style: const TextStyle(fontSize: 16)), onTap: onTap, dense: true); } -class _HealthMetric extends StatelessWidget { - final IconData icon; final String label; final String value; final VoidCallback? onTap; - const _HealthMetric({required this.icon, required this.label, required this.value, this.onTap}); - @override Widget build(BuildContext context) => ListTile( - leading: Icon(icon, size: 18, color: const Color(0xFF635BFF)), - title: Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))), - trailing: Text(value, style: TextStyle(fontSize: 16, color: value == '--' ? const Color(0xFF999999) : const Color(0xFF1A1A1A))), - dense: true, +class _HealthMetricChip extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final VoidCallback? onTap; + + const _HealthMetricChip({required this.icon, required this.label, required this.value, this.onTap}); + + @override + Widget build(BuildContext context) => GestureDetector( onTap: onTap, + child: Container( + width: 80, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEDEBFF)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 14, color: const Color(0xFF635BFF)), + const SizedBox(width: 4), + Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[600])), + Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + ]), + ]), + ), ); } @@ -156,15 +189,15 @@ class _ConversationItem extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: const Color(0xFFF8F7FF), borderRadius: BorderRadius.circular(10), ), child: ListTile( leading: Container( - width: 36, - height: 36, + width: 32, + height: 32, decoration: BoxDecoration( color: const Color(0xFFEDEBFF), borderRadius: BorderRadius.circular(8), @@ -172,13 +205,12 @@ class _ConversationItem extends ConsumerWidget { child: Icon(_getAgentIcon(item.agent), size: 16, color: const Color(0xFF635BFF)), ), title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), - subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])), + subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: Colors.grey[500])), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 9, color: Color(0xFFCCCCCC))), - const SizedBox(height: 2), PopupMenuButton( icon: const Icon(Icons.more_vert, size: 14, color: Color(0xFFCCCCCC)), itemBuilder: (_) => [