fix: 7处修复 - 溢出/黑屏/趋势图/欢迎卡片/抽屉
This commit is contained in:
@@ -94,11 +94,16 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
final data = <Map<String, dynamic>>[];
|
||||
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;
|
||||
|
||||
final measured = rng.nextDouble() < hasDataChance;
|
||||
if (measured) {
|
||||
switch (widget.metricType) {
|
||||
case 'blood_pressure':
|
||||
value = 110 + rng.nextInt(40);
|
||||
@@ -119,6 +124,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
default:
|
||||
value = 100 + rng.nextInt(50);
|
||||
}
|
||||
}
|
||||
|
||||
data.add({
|
||||
'date': date,
|
||||
@@ -166,14 +172,18 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
({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<TrendPage> {
|
||||
}
|
||||
|
||||
Map<String, dynamic> _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<TrendPage> {
|
||||
|
||||
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<TrendPage> {
|
||||
'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<TrendPage> {
|
||||
'max': _formatValue(maxVal),
|
||||
'min': _formatValue(minVal),
|
||||
'avg': _formatValue(avgVal),
|
||||
'count': _data.length,
|
||||
'count': validData.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,7 +329,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
// ==================== 当前值卡片 ====================
|
||||
List<Widget> _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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// 点击偏离数据点太远则不响应
|
||||
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<Map<String, dynamic>> 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 = <Offset>[];
|
||||
final points2 = <Offset>[];
|
||||
// 记录每条线的分段信息:每个段是连续非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;
|
||||
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;
|
||||
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. 填充区域(主线) ----
|
||||
// 收尾最后一个段
|
||||
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));
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
}
|
||||
|
||||
// ---- 4. 填充区域(副线,血压) ----
|
||||
if (isDualLine && points2.isNotEmpty) {
|
||||
// ---- 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. 数据线(主线) ----
|
||||
_drawSmoothLine(canvas, points1, _primaryColor);
|
||||
// ---- 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 && points2.isNotEmpty) {
|
||||
_drawSmoothLine(canvas, points2, _secondaryColor);
|
||||
// ---- 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<Offset> points, Color color) {
|
||||
void _drawLine(Canvas canvas, List<Offset> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,17 +20,23 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
bool _taskCardsExpanded = true;
|
||||
double _lastScrollOffset = 0;
|
||||
DateTime? _lastCollapseTime;
|
||||
bool _medicationTaken = false;
|
||||
bool _exerciseDone = false;
|
||||
final Set<ActiveAgent> _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<HomePage> {
|
||||
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<HomePage> {
|
||||
drawer: const HealthDrawer(),
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
// ── 顶部栏 ──
|
||||
_buildHeader(user),
|
||||
@@ -75,14 +81,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
// ── 聊天区域(弹性填充剩余空间) ──
|
||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||
|
||||
// ── 智能体选择器(常驻显示) ──
|
||||
_buildAgentBar(selectedAgent),
|
||||
|
||||
// ── 选中智能体的操作面板 ──
|
||||
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
|
||||
|
||||
// ── 输入框 ──
|
||||
_buildInputBar(context),
|
||||
// ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
|
||||
_buildBottomBar(context, selectedAgent),
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -134,19 +134,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
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<HomePage> {
|
||||
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<HomePage> {
|
||||
|
||||
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<HomePage> {
|
||||
|
||||
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<HomePage> {
|
||||
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<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════ 智能体操作面板(选中后显示) ═════════════════════
|
||||
// ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════
|
||||
|
||||
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<Widget> _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<HomePage> {
|
||||
])));
|
||||
}
|
||||
|
||||
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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MedicationEditPage> {
|
||||
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: [
|
||||
|
||||
@@ -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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,17 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
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<void> sendMessage(String text) async {
|
||||
if (text.trim().isEmpty || state.isStreaming) return;
|
||||
|
||||
|
||||
@@ -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<int>(
|
||||
icon: const Icon(Icons.more_vert, size: 14, color: Color(0xFFCCCCCC)),
|
||||
itemBuilder: (_) => [
|
||||
|
||||
Reference in New Issue
Block a user