Files
AI-Health/health_app/lib/pages/home/widgets/chat_messages_view.dart
MingNian f01fc9268d feat: UI清爽紫蓝风全面改造 + 对话卡片重构 + 配色体系升级
- 新增 AppColors 统一配色方案(清爽紫蓝,参考蚂蚁阿福风格)
- 新增 common_widgets(GradientBorderButton / CardActionButton / IconBox)
- AgentWelcomeCard 美化:渐变 header + AI标签 + 核心功能区 + 快捷操作区
- DataConfirmCard 统一:三合一(健康/药品/运动),可编辑字段列表
- 删除死代码:medicationConfirm/dietAnalysis/reportAnalysis/quickOptions 卡片
- 删除死路由:doctors/profileEdit/editProfile
- AI 对话去除 Markdown 符号(_stripMd),统一行间距 1.5
- 逐字淡入动画修复:key 改用 stableId 避免重复触发
- 健康概览时间修复:前端发送 UTC 避免时区歧义
- PG 数据目录迁入项目 backend/pgdata/,加入 .gitignore
- 新增欧姆龙血压计实施方案文档
2026-06-09 17:04:11 +08:00

1505 lines
66 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_theme.dart';
import '../../../core/navigation_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
/// 对话消息列表
class ChatMessagesView extends ConsumerWidget {
final ScrollController scrollCtrl;
final List<ChatMessage> messages;
const ChatMessagesView({super.key, required this.scrollCtrl, required this.messages});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatState = ref.watch(chatProvider);
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(40),
),
child: const Icon(Icons.health_and_safety, size: 40, color: AppTheme.primary),
),
const SizedBox(height: 16),
Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8),
const Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 17, color: AppColors.textHint)),
],
),
);
}
return ListView.builder(
controller: scrollCtrl,
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return _buildMessageContent(context, ref, msg, chatState);
},
);
}
// ─── 消息分发 ─────────────────────────────────────────────
Widget _buildMessageContent(BuildContext context, WidgetRef ref, ChatMessage msg, ChatState chatState) {
switch (msg.type) {
case MessageType.agentWelcome:
final storedAgent = _parseAgentFromName(msg.metadata?['agent'] as String?);
return _buildAgentWelcomeCard(context, ref, msg, storedAgent);
case MessageType.taskCard:
return _buildTaskCardInChat(context, ref);
case MessageType.dataConfirm:
return _buildDataConfirmCard(context, ref, msg);
default:
if (!msg.isUser && chatState.isStreaming && msg.content.length < 5) {
return _buildThinkingBubble(context, chatState.thinkingText);
}
return _buildTextBubble(context, msg, chatState);
}
}
// ═══════════════════════════════════════════════════════════
// 逐字淡入文本(蚂蚁阿福风格)
// ═══════════════════════════════════════════════════════════
Widget _buildFadeInText(String text, TextStyle style, String stableId, {bool enabled = true}) {
if (!enabled || text.length <= 2) return Text(text, style: style);
return Wrap(
children: List.generate(text.length, (i) {
return TweenAnimationBuilder<double>(
key: ValueKey('fade_${stableId}_$i'),
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder: (_, value, child) => Opacity(opacity: value, child: child),
child: Text(text[i], style: style),
);
}),
);
}
// ═══════════════════════════════════════════════════════════
// 1. AgentWelcomeCard — 智能体欢迎卡片(蚂蚁阿福风格 + 美化版)
// ═══════════════════════════════════════════════════════════
Widget _buildAgentWelcomeCard(BuildContext context, WidgetRef ref, ChatMessage msg, ActiveAgent agent) {
final info = _agentInfo(agent);
final actions = agent.actions;
final features = _getAgentFeatures(agent);
final screenWidth = MediaQuery.of(context).size.width;
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 16),
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(24),
boxShadow: AppColors.cardShadow,
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── 标题区域(清爽蓝紫渐变背景) ──
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.purpleGradientStart.withAlpha(40),
AppColors.blueGradientEnd.withAlpha(30),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 大图标容器
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: AppColors.purpleBlueGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.primaryPurple.withAlpha(30),
blurRadius: 14,
offset: const Offset(0, 5),
),
],
),
child: Icon(info.$1, size: 32, color: Colors.white),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// AI 助手标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withAlpha(160),
borderRadius: BorderRadius.circular(10),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.auto_awesome, size: 14, color: AppColors.iconColor),
const SizedBox(width: 4),
Text('AI 智能助手', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.iconColor)),
]),
),
const SizedBox(height: 10),
Text(info.$2, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w800, color: AppColors.textPrimary, letterSpacing: -0.3)),
const SizedBox(height: 8),
Text(info.$3, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary, height: 1.4)),
],
),
),
],
),
),
// ── 功能特性区 ──
if (features.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 6, height: 18,
decoration: BoxDecoration(
gradient: AppColors.purpleBlueGradient,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
Text('核心功能', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
]),
const SizedBox(height: 14),
Row(
children: features.map((feature) {
return Expanded(
child: Container(
margin: EdgeInsets.only(
left: features.indexOf(feature) == 0 ? 0 : 6,
right: features.indexOf(feature) == features.length - 1 ? 0 : 6,
),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
),
child: Column(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: AppColors.lightGradient,
borderRadius: BorderRadius.circular(14),
),
child: Icon(feature.icon, size: 24, color: AppColors.iconColor),
),
const SizedBox(height: 8),
Text(feature.title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary), textAlign: TextAlign.center),
const SizedBox(height: 2),
Text(feature.desc, style: const TextStyle(fontSize: 13, color: AppColors.textHint), textAlign: TextAlign.center),
],
),
),
);
}).toList(),
),
],
),
),
),
],
// ── 快捷操作按钮区 ──
if (actions.isNotEmpty) ...[
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 6, height: 18,
decoration: BoxDecoration(
gradient: AppColors.purpleBlueGradient,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
Text('快捷操作', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
]),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: actions.map((a) => _buildActionButton(a, context, ref)).toList(),
),
],
),
),
],
// ── 底部提示 ──
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.iconBackground,
borderRadius: BorderRadius.circular(12),
),
child: Row(children: [
Icon(Icons.lightbulb_outline, size: 18, color: AppColors.iconColor),
const SizedBox(width: 8),
Expanded(child: Text('直接描述您的需求AI 会自动为您记录和分析', style: TextStyle(fontSize: 14, color: AppColors.iconColor, fontWeight: FontWeight.w500))),
]),
),
),
],
),
),
);
}
Widget _buildActionButton(_AgentAction a, BuildContext context, WidgetRef ref) {
final screenWidth = MediaQuery.of(context).size.width;
return InkWell(
onTap: () {
if (a.label == '服药打卡') {
_medicationCheckIn(ref, context);
} else if (a.action == 'pickFoodCamera' || a.action == 'pickFoodGallery') {
ref.read(dietActionProvider.notifier).trigger(a.action!);
} else if (a.route != null) {
if (a.route == 'camera' || a.route == 'gallery') {
ref.read(cameraActionProvider.notifier).trigger(a.route!);
} else {
pushRoute(ref, a.route!);
}
}
},
borderRadius: BorderRadius.circular(16),
child: Container(
width: ((screenWidth - 40 - 10) / 2) - 5,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderLight, width: 1.5),
boxShadow: AppColors.cardShadow,
),
child: Row(
children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(
gradient: AppColors.lightGradient,
borderRadius: BorderRadius.circular(12),
),
child: Icon(a.icon, size: 22, color: AppColors.iconColor),
),
const SizedBox(width: 12),
Expanded(child: Text(a.label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary))),
Icon(Icons.chevron_right, size: 18, color: AppColors.border),
],
),
),
);
}
List<_AgentFeature> _getAgentFeatures(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.health => [
_AgentFeature(Icons.trending_up, '趋势分析', '查看历史数据'),
_AgentFeature(Icons.add, '快速录入', '血压心率血糖'),
],
ActiveAgent.diet => [
_AgentFeature(Icons.camera_alt, '拍照识别', '自动识别食物'),
_AgentFeature(Icons.bar_chart, '营养分析', '热量营养成分'),
],
ActiveAgent.medication => [
_AgentFeature(Icons.list, '用药清单', '管理所有药品'),
_AgentFeature(Icons.alarm, '智能提醒', '按时服药提醒'),
],
ActiveAgent.consultation => [
_AgentFeature(Icons.message, '在线问诊', '专业医生解答'),
_AgentFeature(Icons.local_hospital, '科室选择', '精准匹配'),
],
ActiveAgent.report => [
_AgentFeature(Icons.upload, '上传报告', '支持多种格式'),
_AgentFeature(Icons.search, 'AI解读', '智能分析报告'),
],
ActiveAgent.exercise => [
_AgentFeature(Icons.calendar_month, '运动计划', '科学规划'),
_AgentFeature(Icons.check_circle, '打卡记录', '坚持完成'),
],
_ => [
_AgentFeature(Icons.health_and_safety, '健康管理', '全方位呵护'),
_AgentFeature(Icons.chat, '智能咨询', '随时答疑'),
],
};
}
Widget _buildDoctorCards(WidgetRef ref) {
const doctors = [
{'name': '张明', 'title': '主任医师', 'dept': '心脏康复科', 'desc': '冠心病、高血压术后管理', 'id': '468b82e2-d95a-4436-bff6-a50eecf99a66'},
{'name': '李芳', 'title': '副主任医师', 'dept': '营养科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'd4148733-b538-4398-af17-0c7592fc0c2d'},
{'name': '王建国', 'title': '主任医师', 'dept': '心血管内科', 'desc': '术后营养指导、饮食方案制定', 'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3'},
];
return Row(
children: [
for (var i = 0; i < doctors.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: _doctorCard(doctors[i], ref)),
],
],
);
}
Widget _doctorCard(Map<String, String> doc, WidgetRef ref) {
return InkWell(
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryLight),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
CircleAvatar(
radius: 22,
backgroundColor: AppTheme.primaryLight,
child: Text(doc['name']![0], style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppTheme.primary)),
),
const SizedBox(height: 6),
Text(doc['name']!, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
Text(doc['title']!, style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(4),
),
child: Text(doc['dept']!, style: const TextStyle(fontSize: 13, color: AppTheme.primary)),
),
const SizedBox(height: 4),
Text(
doc['desc']!,
style: const TextStyle(fontSize: 13, color: AppColors.textSecondary, height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
]),
),
);
}
// ═══════════════════════════════════════════════════════════
// 2. DataConfirmCard — 统一确认卡片(紫白蓝风格)
// ═══════════════════════════════════════════════════════════
Widget _buildDataConfirmCard(BuildContext context, WidgetRef ref, ChatMessage msg) {
final meta = msg.metadata ?? <String, dynamic>{};
final backendType = meta['type'] as String? ?? '';
final screenWidth = MediaQuery.of(context).size.width;
// 识别是哪种数据类型
final isMedication = backendType == 'medication' || (meta['name'] != null && meta['name'].toString().isNotEmpty && backendType != 'exercise');
final isExercise = backendType == 'exercise';
final isHealth = !isMedication && !isExercise;
// 根据类型获取显示字段
String title = '数据确认';
IconData titleIcon = Icons.assignment;
String mainLabel = '';
String mainValue = '';
String mainUnit = '';
bool abnormal = meta['abnormal'] as bool? ?? false;
String recordTime = meta['recordTime'] as String? ?? '';
List<_ConfirmField> fields = [];
if (isMedication) {
// 药品录入
title = '药品录入确认';
titleIcon = Icons.medication_liquid_outlined;
mainLabel = meta['name'] as String? ?? '';
mainValue = meta['dosage'] as String? ?? '';
mainUnit = '';
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final nameValue = meta['药品名称'] as String? ?? mainLabel;
fields.add(_ConfirmField(label: '药品名称', value: nameValue, icon: Icons.medication_outlined, editable: true));
if (mainValue.isNotEmpty) {
final dosageValue = meta['剂量'] as String? ?? mainValue;
fields.add(_ConfirmField(label: '剂量', value: dosageValue, icon: Icons.local_pharmacy_outlined, editable: true));
}
final time = meta['time'] as String? ?? '';
if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time;
fields.add(_ConfirmField(label: '服药时间', value: timeValue, icon: Icons.access_time, editable: true));
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(_ConfirmField(label: '频率', value: freqValue, icon: Icons.repeat_outlined, editable: true));
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(_ConfirmField(label: '服用天数', value: durationValue, icon: Icons.event_available_outlined, editable: true));
}
} else if (isExercise) {
// 运动计划
title = '运动计划确认';
titleIcon = Icons.directions_run_outlined;
mainLabel = meta['name'] as String? ?? meta['activity'] as String? ?? '运动计划';
mainValue = meta['duration'] as String? ?? '';
mainUnit = meta['duration_unit'] as String? ?? '分钟';
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final typeValue = meta['运动类型'] as String? ?? mainLabel;
fields.add(_ConfirmField(label: '运动类型', value: typeValue, icon: Icons.fitness_center_outlined, editable: true));
if (mainValue.isNotEmpty) {
final durationValue = meta['运动时长'] as String? ?? '$mainValue $mainUnit';
fields.add(_ConfirmField(label: '运动时长', value: durationValue, icon: Icons.timer_outlined, editable: true));
}
final intensity = meta['intensity'] as String? ?? '';
if (intensity.isNotEmpty) {
final intensityValue = meta['运动强度'] as String? ?? intensity;
fields.add(_ConfirmField(label: '运动强度', value: intensityValue, icon: Icons.local_fire_department_outlined, editable: true));
}
final calories = meta['calories'] as String? ?? meta['calorie'] as String? ?? '';
if (calories.isNotEmpty) {
final caloriesValue = meta['消耗热量'] as String? ?? '$calories kcal';
fields.add(_ConfirmField(label: '消耗热量', value: caloriesValue, icon: Icons.local_fire_department, editable: true));
}
final schedule = meta['schedule'] as String? ?? '';
if (schedule.isNotEmpty) {
final scheduleValue = meta['计划时间'] as String? ?? schedule;
fields.add(_ConfirmField(label: '计划时间', value: scheduleValue, icon: Icons.calendar_month_outlined, editable: true));
}
} else {
// 健康指标
title = '健康数据确认';
titleIcon = Icons.monitor_heart_outlined;
mainLabel = _getMetricName(backendType);
mainValue = meta['value'] as String? ?? '';
mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType);
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final metricValue = meta[mainLabel] as String? ?? '$mainValue $mainUnit';
fields.add(_ConfirmField(label: mainLabel, value: metricValue, icon: Icons.favorite_outline, editable: true));
final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add(_ConfirmField(label: '记录时间', value: timeValue, icon: Icons.access_time, editable: true));
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(_ConfirmField(label: '备注', value: noteValue, icon: Icons.edit_note_outlined, editable: true));
}
}
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 16),
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(24),
boxShadow: AppColors.cardShadow,
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── 标题区域(清爽蓝紫渐变背景) ──
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.purpleGradientStart.withAlpha(45),
AppColors.blueGradientEnd.withAlpha(25),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: AppColors.purpleBlueGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: AppColors.primaryPurple.withAlpha(35), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Icon(titleIcon, size: 28, color: Colors.white),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 6),
Row(
children: [
Icon(Icons.access_time, size: 15, color: AppColors.iconColor),
const SizedBox(width: 4),
Text(recordTime, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
],
),
],
),
),
],
),
),
// ── 主要数据展示区(大卡片) ──
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
gradient: AppColors.lightGradient,
borderRadius: BorderRadius.circular(18),
),
child: Center(
child: isHealth
? Text(_getMetricIcon(backendType), style: const TextStyle(fontSize: 36))
: Icon(
isMedication ? Icons.medication_liquid_outlined : Icons.directions_run_outlined,
size: 36,
color: AppColors.iconColor,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(mainLabel, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)),
const SizedBox(height: 8),
RichText(
text: TextSpan(
children: [
TextSpan(
text: mainValue.isNotEmpty ? mainValue : '',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w800,
color: abnormal ? AppColors.error : AppColors.textPrimary,
letterSpacing: -0.5,
),
),
if (mainUnit.isNotEmpty)
TextSpan(
text: ' $mainUnit',
style: TextStyle(
fontSize: 19,
color: abnormal ? AppColors.error : AppColors.iconColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
],
),
),
),
// ── 异常提示 ──
if (abnormal) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.error.withAlpha(10),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.error.withAlpha(30)),
),
child: Row(
children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(color: AppColors.error.withAlpha(15), borderRadius: BorderRadius.circular(10)),
child: Icon(Icons.warning_amber_rounded, size: 20, color: AppColors.error),
),
const SizedBox(width: 10),
Expanded(child: Text('数值偏高,建议关注并咨询医生', style: TextStyle(fontSize: 16, color: AppColors.error, fontWeight: FontWeight.w500))),
],
),
),
),
],
// ── 详细字段列表(可点击修改) ──
if (fields.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
for (int i = 0; i < fields.length; i++) ...[
if (i > 0) Container(height: 1, color: AppColors.borderLight, margin: const EdgeInsets.symmetric(horizontal: 16)),
_buildFieldRow(fields[i], msg, ref),
],
],
),
),
),
],
// ── 确认按钮(白色底+渐变边框) ──
const SizedBox(height: 18),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderLight, width: 1.5),
boxShadow: AppColors.cardShadow,
),
child: msg.confirmed
? Container(
decoration: BoxDecoration(
gradient: AppColors.successButtonGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: AppColors.buttonShadow,
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.check_circle_outline, size: 22, color: Colors.white),
const SizedBox(width: 8),
const Text('录入成功', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Colors.white)),
]),
)
: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
msg.confirmed = true;
if (isMedication) ref.invalidate(medicationListProvider);
if (isHealth) ref.invalidate(latestHealthProvider);
ref.read(chatProvider.notifier).markNeedsRebuild();
},
borderRadius: BorderRadius.circular(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(
gradient: AppColors.purpleBlueGradient,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.check, size: 18, color: Colors.white),
),
const SizedBox(width: 10),
Text('确认录入', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
],
),
),
),
),
),
const SizedBox(height: 20),
],
),
),
);
}
Widget _buildFieldRow(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
final isEditing = field.label == msg.metadata?['_editingField'];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 42, height: 42,
decoration: BoxDecoration(color: AppColors.iconBackground, borderRadius: BorderRadius.circular(12)),
child: Icon(field.icon, size: 22, color: AppColors.iconColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(field.label, style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
const SizedBox(height: 4),
if (!field.editable)
Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary))
else if (isEditing)
_buildEditableTextField(field, msg, ref)
else
InkWell(
onTap: () {
msg.metadata?['_editingField'] = field.label;
ref.read(chatProvider.notifier).markNeedsRebuild();
},
child: Row(
children: [
Expanded(child: Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary))),
const SizedBox(width: 8),
Icon(Icons.edit_outlined, size: 18, color: AppColors.border),
],
),
),
],
),
),
],
),
);
}
Widget _buildEditableTextField(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
return StatefulBuilder(
builder: (context, setState) {
final controller = TextEditingController(text: field.value);
return Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.primaryBlue.withAlpha(150), width: 1.5),
borderRadius: BorderRadius.circular(8),
color: AppColors.backgroundSecondary,
),
child: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: InputBorder.none,
),
onSubmitted: (value) {
msg.metadata?[field.label] = value;
msg.metadata?['_editingField'] = null;
ref.read(chatProvider.notifier).markNeedsRebuild();
},
onTapOutside: (_) {
msg.metadata?[field.label] = controller.text;
msg.metadata?['_editingField'] = null;
ref.read(chatProvider.notifier).markNeedsRebuild();
},
),
);
},
);
}
void _editField(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
// 字段可点击修改的入口,后续可接入 showDialog 实现
}
// ═══════════════════════════════════════════════════════════
// 3. ThinkingBubble — 思考动画
// ═══════════════════════════════════════════════════════════
Widget _buildThinkingBubble(BuildContext context, String? thinkingText) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
border: Border.all(color: AppColors.border, width: 1.5),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(strokeWidth: 2.2, color: AppTheme.primary),
),
const SizedBox(width: 10),
Text(thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', style: const TextStyle(fontSize: 17, color: AppColors.textHint)),
],
),
),
);
}
Widget _buildTextBubble(BuildContext context, ChatMessage msg, ChatState? chatState) {
final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] as String?;
final localPath = msg.metadata?['localImagePath'] as String?;
final hasImage = imageUrl != null || localPath != null;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 20 : 4),
topRight: Radius.circular(isUser ? 4 : 20),
bottomLeft: const Radius.circular(20),
bottomRight: const Radius.circular(20),
),
border: isUser ? null : Border.all(color: AppColors.border, width: 1.5),
boxShadow: isUser ? [] : [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 文字内容
if (isUser)
SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.5))
else if (chatState?.isStreaming ?? false)
// 流式输出中:逐字淡入
_buildFadeInText(_stripMd(msg.content), const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5), msg.id)
else
SelectableText(_stripMd(msg.content), style: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5)),
// 图片缩略图(在文字下方)
if (hasImage)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => _showFullImage(context, localPath ?? imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160, maxHeight: 120),
child: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(imageUrl, fit: BoxFit.cover, errorBuilder: (_, e, s) => Container(
width: 80, height: 60,
decoration: BoxDecoration(color: AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.image, size: 28, color: AppColors.textHint),
))
: const SizedBox.shrink(),
),
),
),
),
if (!isUser && msg.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(children: [
const CircleAvatar(radius: 10, backgroundColor: AppTheme.primaryLight, child: Icon(Icons.chat_bubble_outline, size: 17, color: AppTheme.primary)),
const SizedBox(width: 6),
const Text('健康管家', style: TextStyle(fontSize: 15, color: AppColors.textHint)),
const SizedBox(width: 4),
const Text('仅供参考', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
]),
),
],
),
),
);
}
// ═══════════════════════════════════════════════════════════
// 公共组件:通用按钮
// ═══════════════════════════════════════════════════════════
Widget _cardFilledBtn(String label, IconData icon, {VoidCallback? onTap}) {
return ElevatedButton(
onPressed: onTap ?? () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 11),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, size: 19),
const SizedBox(width: 5),
Text(label, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
]),
);
}
Widget _cardOutlineBtn(String label, IconData icon, {VoidCallback? onTap}) {
return OutlinedButton(
onPressed: onTap ?? () {},
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(color: AppTheme.primary, width: 1.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 11),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, size: 18),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
]),
);
}
// ═══════════════════════════════════════════════════════════
// 工具方法
// ═══════════════════════════════════════════════════════════
static void _showFullImage(BuildContext context, String? path) {
if (path == null) return;
showDialog(
context: context,
builder: (ctx) => Dialog(
backgroundColor: Colors.black,
insetPadding: const EdgeInsets.all(24),
child: Stack(alignment: Alignment.center, children: [
ClipRRect(borderRadius: BorderRadius.circular(12), child: InteractiveViewer(child: path.startsWith('http')
? Image.network(path, fit: BoxFit.contain)
: Image.file(File(path), fit: BoxFit.contain))),
Positioned(top: 8, right: 8, child: GestureDetector(
onTap: () => Navigator.pop(ctx),
child: Container(padding: const EdgeInsets.all(6), decoration: BoxDecoration(color: Colors.white54, shape: BoxShape.circle), child: const Icon(Icons.close, size: 21)),
)),
]),
),
);
}
/// 去掉 AI 返回的 Markdown 标记(**加粗**、*斜体*、`代码`、标题、列表符号等)
static String _stripMd(String text) {
return text
.replaceAll(RegExp(r'\*\*(.+?)\*\*'), r'$1')
.replaceAll(RegExp(r'\*(.+?)\*'), r'$1')
.replaceAll(RegExp(r'^#{1,6}\s+', multiLine: true), '')
.replaceAll(RegExp(r'^[\-\*]\s+', multiLine: true), '')
.replaceAll(RegExp(r'`(.+?)`'), r'$1')
.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1')
.replaceAll(RegExp(r'\n{3,}'), '\n\n')
.trim();
}
String _freqLabel(String freq) {
switch (freq.toLowerCase()) {
case 'daily': return '每天';
case 'everyotherday': return '隔天';
case 'weekly': return '每周';
default: return freq;
}
}
String _formatTime(DateTime dt) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final thatDay = DateTime(dt.year, dt.month, dt.day);
if (thatDay == today) {
return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
if (thatDay == today.subtract(const Duration(days: 1))) {
return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
String _getMetricUnit(String type) {
switch (type.toLowerCase()) {
case 'blood_pressure': return 'mmHg';
case 'heart_rate': return 'bpm';
case 'glucose': return 'mmol/L';
case 'spo2': return '%';
case 'weight': return 'kg';
case 'exercise': return '分钟/天';
default: return '';
}
}
String _getMetricIcon(String type) {
switch (type.toLowerCase()) {
case 'blood_pressure': return '🩺';
case 'heart_rate': return '💓';
case 'glucose': return '💉';
case 'spo2': return '🫁';
case 'weight': return '⚖️';
case 'exercise': return '🏃';
default: return '📊';
}
}
String _getMetricName(String type) {
switch (type.toLowerCase()) {
case 'blood_pressure': return '血压';
case 'heart_rate': return '心率';
case 'glucose': return '血糖';
case 'spo2': return '血氧';
case 'weight': return '体重';
case 'exercise': return '运动计划';
default: return '健康指标';
}
}
static void _medicationCheckIn(WidgetRef ref, BuildContext context) async {
try {
final api = ref.read(apiClientProvider);
final reminders = await ref.read(medicationReminderProvider.future);
if (reminders.isEmpty) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('暂无待服药记录'), backgroundColor: AppColors.warning),
);
return;
}
for (final m in reminders) {
final id = m['id']?.toString() ?? '';
final time = m['scheduledTime']?.toString() ?? '';
if (id.isEmpty) continue;
await api.post('/api/medications/$id/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'});
}
ref.invalidate(medicationReminderProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('打卡成功 已记录 ${reminders.length} 项服药'),
backgroundColor: AppTheme.success,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red),
);
}
}
static _AgentColors _agentColors(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.consultation => _AgentColors(
gradient: [const Color(0xFFC5D5F8), const Color(0xFFA0B8F0), const Color(0xFF7B98E0)],
bg: const Color(0xFFF0F4FF),
border: const Color(0xFFD8E0FA),
iconBg: const Color(0xFFE4ECFC),
accent: const Color(0xFF7B98E0),
),
ActiveAgent.health => _AgentColors(
gradient: [const Color(0xFFB8E6CF), const Color(0xFF8ED4AE), const Color(0xFF5FB88D)],
bg: const Color(0xFFF0FAF4),
border: const Color(0xFFD0ECD8),
iconBg: const Color(0xFFE4F8EC),
accent: const Color(0xFF5FB88D),
),
ActiveAgent.diet => _AgentColors(
gradient: [const Color(0xFFFFD8B8), const Color(0xFFFFC896), const Color(0xFFF0A060)],
bg: const Color(0xFFFFF6F0),
border: const Color(0xFFFFE8D4),
iconBg: const Color(0xFFFFEEDC),
accent: const Color(0xFFF0A060),
),
ActiveAgent.medication => _AgentColors(
gradient: [const Color(0xFFFFD4E0), const Color(0xFFFFB8CC), const Color(0xFFE898A8)],
bg: const Color(0xFFFFF0F4),
border: const Color(0xFFFFE0E8),
iconBg: const Color(0xFFFFE8EE),
accent: const Color(0xFFE898A8),
),
ActiveAgent.report => _AgentColors(
gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)],
bg: const Color(0xFFF8F4FF),
border: const Color(0xFFECE4F8),
iconBg: const Color(0xFFF0E8FC),
accent: const Color(0xFFA898D8),
),
ActiveAgent.exercise => _AgentColors(
gradient: [const Color(0xFFB8E0E0), const Color(0xFF90D0D0), const Color(0xFF68B4B4)],
bg: const Color(0xFFF0FAFA),
border: const Color(0xFFD4ECEC),
iconBg: const Color(0xFFE4F4F4),
accent: const Color(0xFF68B4B4),
),
_ => _AgentColors(
gradient: [const Color(0xFFC5D0F8), const Color(0xFFA0B0F0), const Color(0xFF7B90E0)],
bg: const Color(0xFFF5F5FF),
border: const Color(0xFFE0E0F8),
iconBg: const Color(0xFFEDEDFC),
accent: const Color(0xFF7B90E0),
),
};
}
static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.health => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'),
ActiveAgent.diet => (Icons.restaurant, '拍饮食', '拍照识别食物热量和营养成分'),
ActiveAgent.medication => (Icons.medication, '药管家', '管理药品、提醒服药、追踪用量'),
ActiveAgent.consultation => (Icons.local_hospital, '问诊', '在线咨询医生,描述症状获取建议'),
ActiveAgent.report => (Icons.assignment, '看报告', '上传体检报告AI 辅助解读'),
ActiveAgent.exercise => (Icons.directions_run, '运动', '制定运动计划,打卡记录进度'),
_ => (Icons.smart_toy, 'AI 助手', '您的智能健康管家'),
};
}
static ActiveAgent _parseAgentFromName(String? name) {
switch (name) {
case 'consultation': return ActiveAgent.consultation;
case 'health': return ActiveAgent.health;
case 'diet': return ActiveAgent.diet;
case 'medication': return ActiveAgent.medication;
case 'report': return ActiveAgent.report;
case 'exercise': return ActiveAgent.exercise;
default: return ActiveAgent.default_;
}
}
Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) {
final health = ref.watch(latestHealthProvider);
final reminders = ref.watch(medicationReminderProvider);
return health.when(
data: (data) => _taskCardBubble(context, ref, data, reminders),
loading: () => _taskCardBubble(context, ref, {}, const AsyncValue.loading()),
error: (_, e) => _taskCardBubble(context, ref, {}, const AsyncValue.loading()),
);
}
Widget _taskCardBubble(BuildContext context, WidgetRef ref, Map<String, dynamic> healthData, AsyncValue<List<Map<String, dynamic>>> reminders) {
final now = DateTime.now();
final tasks = <Widget>[];
// 1. 健康数据摘要行
final bp = healthData['BloodPressure'];
final bpText = bp is Map ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' : null;
final hr = healthData['HeartRate'];
final hrText = hr is int ? '$hr' : null;
final bs = healthData['BloodSugar'];
final bsText = bs is num ? '$bs' : null;
final bo = healthData['BloodOxygen'];
final boText = bo is num ? '$bo' : null;
final wt = healthData['Weight'];
final wtText = wt is num ? '$wt' : null;
final allNull = bpText == null && hrText == null && bsText == null && boText == null && wtText == null;
if (!allNull) {
tasks.add(_taskRow(context, Icons.check_circle, '今日已记录', trailing: [
if (bpText != null) '血压 $bpText',
if (hrText != null) '心率 $hrText',
if (bsText != null) '血糖 $bsText',
if (boText != null) '血氧 $boText',
if (wtText != null) '体重 $wtText',
].join(' · '), status: 'done', onTap: () => pushRoute(ref, 'trend')));
}
// 2. 用药提醒 — 一行汇总
reminders.whenOrNull(data: (meds) {
final overdue = meds.where((m) => m['status'] == 'overdue').length;
final upcoming = meds.where((m) => m['status'] == 'upcoming').length;
final taken = meds.where((m) => m['status'] == 'taken').length;
final total = overdue + upcoming + taken;
if (total > 0) {
final parts = <String>[];
if (overdue > 0) parts.add('$overdue项过期');
if (upcoming > 0) parts.add('$upcoming项待服');
if (taken > 0) parts.add('$taken项已服');
tasks.add(_taskRow(
context, Icons.medication_rounded, '用药打卡 ($total项)',
trailing: parts.join(' · '),
status: overdue > 0 ? 'overdue' : upcoming > 0 ? 'pending' : 'done',
onTap: () => pushRoute(ref, 'medCheckIn'),
));
}
});
final hasMeds = reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty;
if (!hasMeds) {
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
}
// 3. 运动 — 使用 currentExercisePlanProvider 同步读取
final exercisePlan = ref.watch(currentExercisePlanProvider);
exercisePlan.whenOrNull(data: (plan) {
if (plan != null) {
final items = (plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = now.weekday % 7; // Dart: Mon=1...Sun=7 → C#: Sun=0 Mon=1...Sat=6
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['dayOfWeek'] == today, orElse: () => null);
if (todayItem != null && todayItem['isRestDay'] != true) {
final done = todayItem['isCompleted'] == true;
final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30';
tasks.add(_taskRow(context, Icons.directions_run,
'$name $dur分钟',
status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'),
onTap: () => pushRoute(ref, 'exercisePlan'),
));
}
}
});
// 4. 异常指标
if (bp is Map) {
final s = bp['systolic'];
if (s is int && s >= 140) {
tasks.add(_taskRow(
context, Icons.warning_amber_rounded, '血压 $s/${bp['diastolic'] ?? '--'} 偏高',
status: 'warning',
onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
));
}
}
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.today, size: 21, color: AppTheme.primary),
const SizedBox(width: 8),
const Text('今日任务', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
]),
const SizedBox(height: 10),
...tasks,
]),
);
}
Widget _taskRow(BuildContext context, IconData icon, String label, {String status = 'pending', String? trailing, VoidCallback? onTap}) {
final colors = {'done': AppTheme.success, 'warning': AppColors.warning, 'pending': AppColors.textHint, 'overdue': AppTheme.error};
final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined, 'overdue': Icons.error};
final isOverdue = status == 'overdue';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
Container(width: 30, height: 30, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 18, color: AppTheme.primary)),
const SizedBox(width: 10),
Expanded(child: Text(trailing ?? label, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary))),
Icon(icons[status] ?? Icons.circle_outlined, size: 21, color: colors[status] ?? Colors.grey),
]),
),
),
);
}
}
// ════════════════════════════════════════════════════════════════
// 内部数据类
// ════════════════════════════════════════════════════════════════
typedef _AgentIcon = IconData;
class _AgentColors {
final List<Color> gradient;
final Color bg;
final Color border;
final Color iconBg;
final Color accent;
const _AgentColors({
required this.gradient,
required this.bg,
required this.border,
required this.iconBg,
required this.accent,
});
}
class _AgentAction {
final String label;
final IconData icon;
final bool isWide;
final String? route;
final String? action;
const _AgentAction({required this.label, required this.icon, this.isWide = false, this.route, this.action});
}
class _AgentFeature {
final IconData icon;
final String title;
final String desc;
const _AgentFeature(this.icon, this.title, this.desc);
}
class _ConfirmField {
final String label;
final String value;
final IconData icon;
final bool editable;
const _ConfirmField({
required this.label,
required this.value,
this.icon = Icons.info_outline,
this.editable = false,
});
}
final _agentActions = <ActiveAgent, List<_AgentAction>>{
ActiveAgent.health: [
_AgentAction(label: '查看趋势', icon: Icons.trending_up, isWide: true, route: 'trend'),
],
ActiveAgent.diet: [
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, action: 'pickFoodCamera'),
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, action: 'pickFoodGallery'),
],
ActiveAgent.medication: [
_AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true, route: 'medications'),
_AgentAction(label: '服药打卡', icon: Icons.check_circle_outline, isWide: true),
],
ActiveAgent.consultation: [],
ActiveAgent.report: [
_AgentAction(label: '上传报告', icon: Icons.upload_file_outlined, isWide: true, route: 'reports'),
_AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true, route: 'reports'),
],
ActiveAgent.exercise: [
_AgentAction(label: '查看计划', icon: Icons.calendar_month_outlined, isWide: true, route: 'exercisePlan'),
_AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true, route: 'exerciseCreate'),
],
};
extension _AgentActionsExt on ActiveAgent {
List<_AgentAction> get actions => _agentActions[this] ?? [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)];
}
// ════════════════════════════════════════════════════════════════
// 可展开的 AI 建议小组件
// ════════════════════════════════════════════════════════════════
class _ExpandableAdvice extends StatefulWidget {
final String advice;
const _ExpandableAdvice({required this.advice});
@override
State<_ExpandableAdvice> createState() => _ExpandableAdviceState();
}
class _ExpandableAdviceState extends State<_ExpandableAdvice> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.lightbulb_outline, size: 19, color: AppTheme.primary),
const SizedBox(width: 6),
const Text('AI 建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.primary)),
const Spacer(),
Icon(_expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 21, color: AppColors.textHint),
],
),
if (_expanded) ...[
const SizedBox(height: 10),
Text(widget.advice, style: const TextStyle(fontSize: 16, color: Color(0xFF555555), height: 1.6)),
],
],
),
),
);
}
}