- 登录页:输入框白底+渐变紫蓝外框,去掉focus内框;登录按钮白底紫字;协议勾选白底 - 首页胶囊:适老化加大(内边距14x8/字号15/图标17/圆角18) - 欢迎卡片底部提示:各智能体专属引导文案,字号加大颜色加深 - 今日健康卡片:健康指标改一行总结(异常提醒/正常显示);用药显示具体药品名+状态 - 发送按钮:蓝→紫渐变 - 设置页菜单:白色卡片+圆角16+轻阴影 - IP改localhost:adb reverse解决IP变动问题 - 服药打卡快捷按钮:改为跳转打卡页,不再一键全打
1544 lines
68 KiB
Dart
1544 lines
68 KiB
Dart
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.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 _AnimatedCardEntry extends StatefulWidget {
|
||
final Widget child;
|
||
const _AnimatedCardEntry({required this.child});
|
||
|
||
@override State<_AnimatedCardEntry> createState() => _AnimatedCardEntryState();
|
||
}
|
||
|
||
class _AnimatedCardEntryState extends State<_AnimatedCardEntry> with SingleTickerProviderStateMixin {
|
||
late final _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600));
|
||
late final _slide = Tween<Offset>(begin: const Offset(0, 0.12), end: Offset.zero).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||
late final _fade = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||
|
||
@override void initState() { super.initState(); _ctrl.forward(); }
|
||
@override void dispose() { _ctrl.dispose(); super.dispose(); }
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FadeTransition(opacity: _fade, child: SlideTransition(position: _slide, child: widget.child));
|
||
}
|
||
}
|
||
|
||
/// 对话消息列表
|
||
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: false,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
itemCount: messages.length,
|
||
itemBuilder: (context, index) {
|
||
final msg = messages[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);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 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;
|
||
final agentColors = _agentColors(agent);
|
||
final iconGradient = LinearGradient(
|
||
colors: agentColors.gradient,
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
);
|
||
|
||
return _AnimatedCardEntry(child: 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(28),
|
||
border: Border.all(color: Color(0xFFE2E8F0), width: 2),
|
||
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: [
|
||
agentColors.border,
|
||
agentColors.bg,
|
||
],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 大图标容器
|
||
Container(
|
||
width: 64,
|
||
height: 64,
|
||
decoration: BoxDecoration(
|
||
gradient: iconGradient,
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: agentColors.accent.withAlpha(77),
|
||
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),
|
||
boxShadow: AppColors.cardShadowLight,
|
||
),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.auto_awesome, size: 14, color: agentColors.accent),
|
||
const SizedBox(width: 4),
|
||
Text('AI 智能助手', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: agentColors.accent)),
|
||
]),
|
||
),
|
||
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(16, 14, 16, 0),
|
||
child: features.any((f) => f.imageAsset != null)
|
||
? Container(
|
||
decoration: BoxDecoration(
|
||
gradient: iconGradient,
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
padding: const EdgeInsets.all(2),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(18),
|
||
child: Image.asset(
|
||
features.firstWhere((f) => f.imageAsset != null).imageAsset!,
|
||
width: double.infinity, height: 170, fit: BoxFit.fill,
|
||
),
|
||
),
|
||
)
|
||
: Container(
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.border)),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Container(width: 4, height: 16, decoration: BoxDecoration(color: agentColors.accent, borderRadius: BorderRadius.circular(2))),
|
||
const SizedBox(width: 8),
|
||
const Text('核心功能', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary)),
|
||
]),
|
||
const SizedBox(height: 10),
|
||
Row(children: features.map((feature) {
|
||
return Expanded(
|
||
child: Container(
|
||
margin: EdgeInsets.symmetric(horizontal: 3),
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6),
|
||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||
child: Column(children: [
|
||
Container(width: 40, height: 40, decoration: BoxDecoration(color: agentColors.iconBg, borderRadius: BorderRadius.circular(10)),
|
||
child: Icon(feature.icon, size: 20, color: agentColors.accent)),
|
||
const SizedBox(height: 6),
|
||
Text(feature.title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||
Text(feature.desc, style: const TextStyle(fontSize: 11, color: AppColors.textHint)),
|
||
]),
|
||
),
|
||
);
|
||
}).toList()),
|
||
]),
|
||
),
|
||
),
|
||
],
|
||
|
||
// ── 医生选择区(问诊专用)──
|
||
if (agent == ActiveAgent.consultation) ...[
|
||
const SizedBox(height: 14),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: _buildDoctorCards(ref, agentColors),
|
||
),
|
||
],
|
||
|
||
// ── 快捷操作按钮区 ──
|
||
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: iconGradient,
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text('快捷操作', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
for (int i = 0; i < actions.length; i++) ...[
|
||
if (i > 0) const SizedBox(width: 10),
|
||
Expanded(child: _buildActionButton(actions[i], context, ref, agentColors)),
|
||
],
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
// ── 底部提示 ──
|
||
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: agentColors.iconBg,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(children: [
|
||
Icon(Icons.lightbulb_outline, size: 18, color: agentColors.accent),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: Text(_agentTip(agent), style: TextStyle(fontSize: 15, color: AppColors.textPrimary.withAlpha(180), fontWeight: FontWeight.w500))),
|
||
]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildActionButton(_AgentAction a, BuildContext context, WidgetRef ref, _AgentColors colors) {
|
||
return InkWell(
|
||
onTap: () {
|
||
if (a.label == '服药打卡') {
|
||
pushRoute(ref, 'medCheckIn');
|
||
} 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(20),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: AppColors.borderLight, width: 1.5),
|
||
boxShadow: AppColors.cardShadow,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40, height: 40,
|
||
decoration: BoxDecoration(
|
||
color: colors.iconBg,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(a.icon, size: 22, color: colors.accent),
|
||
),
|
||
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, '趋势分析', '查看历史数据', imageAsset: 'assets/images/Data.png'),
|
||
_AgentFeature(Icons.add, '快速录入', '血压心率血糖'),
|
||
],
|
||
ActiveAgent.diet => [
|
||
_AgentFeature(Icons.camera_alt, '拍照识别', '自动识别食物', imageAsset: 'assets/images/diet.png'),
|
||
_AgentFeature(Icons.bar_chart, '营养分析', '热量营养成分'),
|
||
],
|
||
ActiveAgent.medication => [
|
||
_AgentFeature(Icons.list, '用药清单', '管理所有药品', imageAsset: 'assets/images/Medicine.png'),
|
||
_AgentFeature(Icons.alarm, '智能提醒', '按时服药提醒'),
|
||
],
|
||
ActiveAgent.consultation => [
|
||
_AgentFeature(Icons.message, '在线问诊', '专业医生解答'),
|
||
_AgentFeature(Icons.local_hospital, '科室选择', '精准匹配'),
|
||
],
|
||
ActiveAgent.report => [
|
||
_AgentFeature(Icons.upload, '上传报告', '支持多种格式', imageAsset: 'assets/images/Report Analysis.png'),
|
||
_AgentFeature(Icons.search, 'AI解读', '智能分析报告'),
|
||
],
|
||
ActiveAgent.exercise => [
|
||
_AgentFeature(Icons.calendar_month, '运动计划', '科学规划', imageAsset: 'assets/images/sport.png'),
|
||
_AgentFeature(Icons.check_circle, '打卡记录', '坚持完成'),
|
||
],
|
||
_ => [
|
||
_AgentFeature(Icons.health_and_safety, '健康管理', '全方位呵护'),
|
||
_AgentFeature(Icons.chat, '智能咨询', '随时答疑'),
|
||
],
|
||
};
|
||
}
|
||
|
||
|
||
|
||
Widget _buildDoctorCards(WidgetRef ref, _AgentColors colors) {
|
||
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, colors)),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _doctorCard(Map<String, String> doc, WidgetRef ref, _AgentColors colors) {
|
||
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: colors.border),
|
||
),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
CircleAvatar(
|
||
radius: 22,
|
||
backgroundColor: colors.iconBg,
|
||
child: Text(doc['name']![0], style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: colors.accent)),
|
||
),
|
||
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: colors.iconBg,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(doc['dept']!, style: TextStyle(fontSize: 13, color: colors.accent)),
|
||
),
|
||
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 time = meta['time'] as String? ?? '';
|
||
if (time.isNotEmpty) {
|
||
final timeValue = meta['服药时间'] as String? ?? time;
|
||
fields.add(_ConfirmField(label: '服药时间', value: timeValue, editable: true));
|
||
}
|
||
final frequency = meta['frequency'] as String? ?? '';
|
||
if (frequency.isNotEmpty) {
|
||
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
|
||
fields.add(_ConfirmField(label: '频率', value: freqValue, 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, editable: true));
|
||
}
|
||
} else if (isExercise) {
|
||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||
title = '运动计划确认';
|
||
titleIcon = Icons.directions_run_outlined;
|
||
final exName = (meta['value'] ?? meta['name'] ?? meta['exerciseType'] ?? meta['运动类型'] ?? '').toString();
|
||
final exUnitRaw = (meta['unit'] ?? meta['duration_unit'] ?? meta['durationUnit'] ?? '').toString();
|
||
final exDaysRaw = meta['durationDays'] ?? meta['day_count'];
|
||
final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0;
|
||
|
||
// 解析 "每天30分钟" → 频率=每天, 时长=30分钟
|
||
final freqMatch = RegExp(r'^(每天|每周|每月)').firstMatch(exUnitRaw);
|
||
final durMatch = RegExp(r'(\d+分钟)').firstMatch(exUnitRaw);
|
||
final freq = freqMatch?.group(1) ?? '';
|
||
final dur = durMatch?.group(1) ?? exUnitRaw;
|
||
|
||
mainLabel = '运动计划';
|
||
mainValue = exName.isNotEmpty ? exName : '运动计划';
|
||
mainUnit = '';
|
||
|
||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||
if (dur.isNotEmpty) {
|
||
fields.add(_ConfirmField(label: '运动时长', value: dur, editable: true));
|
||
}
|
||
if (freq.isNotEmpty) {
|
||
fields.add(_ConfirmField(label: '频率', value: freq));
|
||
}
|
||
if (exDays > 0) {
|
||
fields.add(_ConfirmField(label: '计划天数', value: '${exDays}天'));
|
||
}
|
||
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
|
||
if (intensity.isNotEmpty) {
|
||
fields.add(_ConfirmField(label: '运动强度', value: intensity, editable: true));
|
||
}
|
||
final calories = (meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '').toString();
|
||
if (calories.isNotEmpty) {
|
||
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal', 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 timeValue = meta['记录时间'] as String? ?? recordTime;
|
||
fields.add(_ConfirmField(label: '记录时间', value: timeValue, editable: true));
|
||
final note = meta['note'] as String? ?? '';
|
||
if (note.isNotEmpty) {
|
||
final noteValue = meta['备注'] as String? ?? note;
|
||
fields.add(_ConfirmField(label: '备注', value: noteValue, editable: true));
|
||
}
|
||
}
|
||
|
||
return _AnimatedCardEntry(child: 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(28),
|
||
border: Border.all(color: Color(0xFFE2E8F0), width: 2),
|
||
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: const BoxDecoration(
|
||
color: Color(0xFFF8F6FF),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardInner,
|
||
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)),
|
||
if (mainValue.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
RichText(
|
||
text: TextSpan(
|
||
children: [
|
||
TextSpan(
|
||
text: 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(20),
|
||
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(20),
|
||
border: Border.all(color: AppColors.borderLight, width: 1.5),
|
||
boxShadow: AppColors.cardShadow,
|
||
),
|
||
child: msg.confirmed
|
||
? Container(
|
||
decoration: BoxDecoration(
|
||
gradient: AppColors.successButtonGradient,
|
||
borderRadius: BorderRadius.circular(20),
|
||
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: () {
|
||
ref.read(chatProvider.notifier).confirmMessage(msg.id);
|
||
},
|
||
borderRadius: BorderRadius.circular(20),
|
||
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: 12),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 80,
|
||
child: Text(field.label, style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: !field.editable
|
||
? Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))
|
||
: isEditing
|
||
? _buildEditableTextField(field, msg, ref)
|
||
: InkWell(
|
||
onTap: () => ref.read(chatProvider.notifier).startEditingField(msg.id, field.label),
|
||
child: Row(children: [
|
||
Expanded(child: Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))),
|
||
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 TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||
decoration: InputDecoration(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: AppColors.primary.withAlpha(80)),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
|
||
),
|
||
),
|
||
onSubmitted: (value) {
|
||
ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, value);
|
||
},
|
||
onTapOutside: (_) {
|
||
ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, controller.text);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
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
|
||
MarkdownBody(
|
||
data: _cleanAiText(msg.content),
|
||
selectable: true,
|
||
styleSheet: MarkdownStyleSheet(
|
||
p: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5),
|
||
strong: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5, fontWeight: FontWeight.w700),
|
||
h1: const TextStyle(fontSize: 21, color: AppColors.textPrimary, fontWeight: FontWeight.w700),
|
||
h2: const TextStyle(fontSize: 20, color: AppColors.textPrimary, fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
|
||
// 图片缩略图(在文字下方)
|
||
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 格式交给 MarkdownBody 渲染)
|
||
static String _cleanAiText(String text) {
|
||
return text
|
||
.replaceAll('\$1', '') // AI 偶尔输出 $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(0xFFB8E0D8), const Color(0xFF8ED4C8), const Color(0xFF68B8AC)],
|
||
bg: const Color(0xFFF0FAF8),
|
||
border: const Color(0xFFD4ECE8),
|
||
iconBg: const Color(0xFFE4F6F2),
|
||
accent: const Color(0xFF68B8AC),
|
||
),
|
||
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 String _agentTip(ActiveAgent agent) => switch (agent) {
|
||
ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"',
|
||
ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"',
|
||
ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"',
|
||
ActiveAgent.consultation => '描述您的症状和不适感,如"最近总是头晕胸闷"',
|
||
ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读',
|
||
ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"',
|
||
_ => '直接描述您的需求,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>[];
|
||
|
||
// ── 解析健康指标 ──
|
||
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;
|
||
|
||
// 异常检测
|
||
final bpAbnormal = bp is Map && bp['systolic'] is int && (bp['systolic'] as int) >= 140;
|
||
final hasAbnormal = bpAbnormal;
|
||
|
||
// ── 1. 健康指标 ──
|
||
const healthIconColor = Color(0xFF3B82F6);
|
||
const healthIconBg = Color(0xFFDBEAFE);
|
||
if (allNull) {
|
||
tasks.add(_taskRow(context, Icons.monitor_heart_outlined, '健康指标',
|
||
trailing: '今日暂无记录,点击录入',
|
||
status: 'pending', iconColor: healthIconColor, iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend')));
|
||
} else if (hasAbnormal) {
|
||
// 有异常时显示异常指标
|
||
final abnormalParts = <String>[];
|
||
if (bpAbnormal) abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高');
|
||
tasks.add(_taskRow(context, Icons.warning_amber_rounded, '健康指标',
|
||
trailing: abnormalParts.join(' · '),
|
||
status: 'warning', iconColor: healthIconColor, iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend')));
|
||
} else {
|
||
// 全部正常
|
||
tasks.add(_taskRow(context, Icons.check_circle, '健康指标',
|
||
trailing: '指标正常',
|
||
status: 'done', iconColor: healthIconColor, iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend')));
|
||
}
|
||
|
||
// ── 2. 运动 ──
|
||
const exIconColor = Color(0xFFF59E0B);
|
||
const exIconBg = Color(0xFFFEF3C7);
|
||
final exercisePlan = ref.watch(currentExercisePlanProvider);
|
||
var hasExercise = false;
|
||
exercisePlan.whenOrNull(data: (plan) {
|
||
if (plan != null) {
|
||
final items = (plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final today = now.weekday % 7;
|
||
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||
(i) => i?['dayOfWeek'] == today, orElse: () => null);
|
||
if (todayItem != null && todayItem['isRestDay'] != true) {
|
||
hasExercise = 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'),
|
||
iconColor: exIconColor, iconBg: exIconBg,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
));
|
||
}
|
||
}
|
||
});
|
||
if (!hasExercise) {
|
||
tasks.add(_taskRow(context, Icons.directions_run, '运动',
|
||
trailing: '暂无今日运动计划',
|
||
status: 'pending', iconColor: exIconColor, iconBg: exIconBg,
|
||
onTap: () => pushRoute(ref, 'exercisePlan')));
|
||
}
|
||
|
||
// ── 3. 用药打卡 ──
|
||
const medIconColor = Color(0xFFEC4899);
|
||
const medIconBg = Color(0xFFFCE7F3);
|
||
reminders.whenOrNull(data: (meds) {
|
||
if (meds.isNotEmpty) {
|
||
final overdueMeds = meds.where((m) => m['status'] == 'overdue').toList();
|
||
final upcomingMeds = meds.where((m) => m['status'] == 'upcoming').toList();
|
||
final takenMeds = meds.where((m) => m['status'] == 'taken').toList();
|
||
// 未服/过期的在前面
|
||
for (final m in [...overdueMeds, ...upcomingMeds]) {
|
||
final name = m['name']?.toString() ?? '药品';
|
||
final time = m['scheduledTime']?.toString() ?? '';
|
||
final isOverdue = m['status'] == 'overdue';
|
||
tasks.add(_taskRow(context, Icons.medication_rounded, name,
|
||
trailing: '${isOverdue ? "❗过期" : "⏳待服"} $time',
|
||
status: isOverdue ? 'overdue' : 'pending',
|
||
iconColor: medIconColor, iconBg: medIconBg,
|
||
onTap: () => pushRoute(ref, 'medCheckIn')));
|
||
}
|
||
// 已服的合并显示
|
||
if (takenMeds.isNotEmpty) {
|
||
final names = takenMeds.map((m) => m['name']?.toString() ?? '药品').join('、');
|
||
tasks.add(_taskRow(context, Icons.check_circle, names,
|
||
trailing: '已服用 (${takenMeds.length}项)',
|
||
status: 'done',
|
||
iconColor: medIconColor, iconBg: medIconBg));
|
||
}
|
||
}
|
||
});
|
||
final hasMeds = reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty;
|
||
if (!hasMeds) {
|
||
tasks.add(_taskRow(context, Icons.medication_rounded, '用药打卡',
|
||
trailing: '暂无用药提醒',
|
||
status: 'pending', iconColor: medIconColor, iconBg: medIconBg,
|
||
onTap: () => pushRoute(ref, 'medications')));
|
||
}
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(28),
|
||
border: Border.all(color: Color(0xFFE2E8F0), width: 2),
|
||
boxShadow: AppColors.cardShadow,
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Container(
|
||
width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||
decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)])),
|
||
child: Row(children: [
|
||
Container(width: 28, height: 28, decoration: BoxDecoration(color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(8)),
|
||
child: const Icon(Icons.health_and_safety, size: 16, color: AppColors.primary)),
|
||
const SizedBox(width: 8),
|
||
const Text('今日健康', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||
const Spacer(),
|
||
Text('${now.month}月${now.day}日', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
|
||
]),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
if (tasks.isEmpty)
|
||
_taskRow(context, Icons.check_circle, '今日暂无待办', status: 'done')
|
||
else
|
||
...tasks,
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _taskRow(BuildContext context, IconData icon, String label, {String status = 'pending', String? trailing, VoidCallback? onTap, Color? iconColor, Color? iconBg}) {
|
||
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';
|
||
final ic = iconColor ?? AppTheme.primary;
|
||
final ibg = iconBg ?? AppTheme.primaryLight;
|
||
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: ibg, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 18, color: ic)),
|
||
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;
|
||
final String? imageAsset;
|
||
|
||
const _AgentFeature(this.icon, this.title, this.desc, {this.imageAsset});
|
||
}
|
||
|
||
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)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
}
|