Files
AI-Health/health_app/lib/pages/home/widgets/chat_messages_view.dart
MingNian 431b72d49a feat: Cupertino滚轮选择器 + 品牌更名 + UI全面优化
- 日期/时间/次数选择器统一改为底部弹出Cupertino滚轮,无单位标签,双横线选区
- App更名为"小脉健康",副标题"血管病患者的AI健康管理助手"
- 健康仪表盘及侧边栏移除体重指标,侧边栏背景改为蓝白渐变
- 健康档案按钮移入功能入口网格,与其他入口样式统一
- 记数据智能体配色改为绿色渐变(#A1FFCE→#69DB8F)
- 隐私协议/关于页从Text改为MarkdownBody正确渲染
- 运动计划/饮食记录卡片添加边框和阴影
- 服药次数从chip改为滚轮选择器
- 日期显示格式统一为空格分隔(1999 01 01)
- 健康档案页背景改为纯白
2026-06-22 13:59:37 +08:00

2113 lines
73 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_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_theme.dart';
import '../../../core/navigation_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(
'开始和小脉健康对话吧',
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.trim().isEmpty) {
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 screenWidth = MediaQuery.of(context).size.width;
final agentColors = _agentColors(agent);
final artworkPath = _agentArtworkPath(agent);
return _AnimatedCardEntry(
child: Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 16),
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF6B5CE7).withValues(alpha: 0.12),
blurRadius: 28,
offset: const Offset(0, 14),
),
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── 标题区域 ──
Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white,
agentColors.bg.withValues(alpha: 0.86),
agentColors.accent.withValues(alpha: 0.12),
],
stops: const [0.0, 0.58, 1.0],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
Positioned(
right: 18,
top: 18,
child: _MiniSignalStrip(colors: agentColors),
),
Positioned(
right: 18,
bottom: 18,
child: _LinearAccentMark(colors: agentColors),
),
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(18, 20, 18, 22),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AgentMark(icon: info.$1, colors: agentColors),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.76),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: agentColors.accent.withValues(
alpha: 0.18,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.graphic_eq,
size: 14,
color: agentColors.accent,
),
const SizedBox(width: 4),
Text(
'AI 智能助手',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: agentColors.accent,
),
),
],
),
),
const SizedBox(height: 9),
Text(
info.$2,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
info.$3,
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
height: 1.4,
),
),
],
),
),
],
),
),
],
),
// ── 插画展示 ──
Image.asset(
artworkPath,
width: double.infinity,
height: 206,
fit: BoxFit.cover,
),
// ── AI问诊引导 ──
if (agent == ActiveAgent.consultation) ...[
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F4FF),
borderRadius: BorderRadius.circular(14),
),
child: const Text(
'在下方输入框描述您的症状AI医生将为您提供专业建议',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
),
),
],
if (actions.isNotEmpty) ...[
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (int i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
Flexible(
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.bg,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: agentColors.accent.withValues(alpha: 0.14),
),
),
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(28),
child: Container(
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: BorderRadius.circular(28),
),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(26.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ShaderMask(
shaderCallback: (bounds) =>
AppColors.actionOutlineGradient.createShader(bounds),
child: Icon(a.icon, size: 24, color: Colors.white),
),
const SizedBox(width: 10),
Text(
a.label,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
),
);
}
String _agentArtworkPath(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.consultation => 'assets/branding/agent_consultation_card.png',
ActiveAgent.health => 'assets/branding/agent_health_card.png',
ActiveAgent.diet => 'assets/branding/agent_diet_card.png',
ActiveAgent.medication => 'assets/branding/agent_medication_card.png',
ActiveAgent.report => 'assets/branding/agent_report_card.png',
ActiveAgent.exercise => 'assets/branding/agent_exercise_card.png',
_ => 'assets/branding/agent_health_card.png',
};
}
// ═══════════════════════════════════════════════════════════
// 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),
);
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(
_ConfirmField(label: '频率', value: freqValue),
);
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(
_ConfirmField(label: '服用天数', value: durationValue),
);
}
} 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));
}
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),
);
}
final calories =
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
.toString();
if (calories.isNotEmpty) {
fields.add(
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
);
}
} 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),
);
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(
_ConfirmField(label: '备注', value: noteValue),
);
}
}
return _AnimatedCardEntry(
child: Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 16),
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.10),
blurRadius: 28,
offset: const Offset(0, 14),
),
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── 标题区域 ──
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)],
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.auraIndigo.withValues(alpha: 0.18),
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(
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: BorderRadius.circular(20),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 68,
height: 68,
decoration: BoxDecoration(
gradient: AppColors.lightGradient,
borderRadius: BorderRadius.circular(18),
),
child: Center(
child: isHealth
? Icon(
_getMetricIconData(backendType),
size: 34,
color: AppColors.primaryDark,
)
: 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]),
],
],
),
),
),
],
// ── 确认按钮(白色底+渐变边框) ──
const SizedBox(height: 18),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
width: double.infinity,
height: 56,
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.auraIndigo.withValues(alpha: 0.18),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: msg.confirmed
? Container(
decoration: BoxDecoration(
gradient: AppColors.successButtonGradient,
borderRadius: BorderRadius.circular(18),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle_outline,
size: 22,
color: Colors.white,
),
SizedBox(width: 8),
Text(
'录入成功',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
)
: Material(
color: Colors.transparent,
child: InkWell(
onTap: () async {
final error = await ref
.read(chatProvider.notifier)
.confirmMessage(msg.id);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
}
},
borderRadius: BorderRadius.circular(18),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.check_rounded,
size: 19,
color: Colors.white,
),
),
const SizedBox(width: 10),
const Text(
'确认录入',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
],
),
),
),
),
),
),
const SizedBox(height: 20),
],
),
),
),
);
}
Widget _buildFieldRow(_ConfirmField field) {
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: Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════
// 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 ? Colors.white : 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: Border.all(
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
width: isUser ? 1 : 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: AppColors.textPrimary,
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),
),
],
),
),
],
),
),
);
}
// ═══════════════════════════════════════════════════════════
// 工具方法
// ═══════════════════════════════════════════════════════════
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 '';
}
}
IconData _getMetricIconData(String type) {
switch (type.toLowerCase()) {
case 'blood_pressure':
return Icons.bloodtype_outlined;
case 'heart_rate':
return Icons.monitor_heart_outlined;
case 'glucose':
return Icons.water_drop_outlined;
case 'spo2':
return Icons.air_outlined;
case 'weight':
return Icons.monitor_weight_outlined;
case 'exercise':
return Icons.directions_run_outlined;
default:
return Icons.query_stats_outlined;
}
}
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 _AgentColors _agentColors(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.consultation => _AgentColors(
gradient: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
bg: const Color(0xFFFFF4F6),
border: const Color(0xFFFFDFE7),
iconBg: const Color(0xFFFFEEF2),
accent: const Color(0xFFFF7F98),
verticalGradient: true,
),
ActiveAgent.health => _AgentColors(
gradient: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
bg: const Color(0xFFF4FEF2),
border: const Color(0xFFD4F0C8),
iconBg: const Color(0xFFF0FCEF),
accent: const Color(0xFF52B87A),
verticalGradient: true,
),
ActiveAgent.diet => _AgentColors(
gradient: [Color(0xFFF6D365), Color(0xFFFDA085)],
bg: const Color(0xFFFFF7F0),
border: const Color(0xFFFFE8D4),
iconBg: const Color(0xFFFFF0E4),
accent: const Color(0xFFF89C5B),
verticalGradient: true,
),
ActiveAgent.medication => _AgentColors(
gradient: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
bg: const Color(0xFFF4F6FF),
border: const Color(0xFFDDE6FF),
iconBg: const Color(0xFFEFF3FF),
accent: const Color(0xFF4F8FF7),
verticalGradient: true,
),
ActiveAgent.report => _AgentColors(
gradient: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
bg: const Color(0xFFF8F5FF),
border: const Color(0xFFE9E0FF),
iconBg: const Color(0xFFF2EDFF),
accent: const Color(0xFF9D8AF8),
verticalGradient: true,
),
ActiveAgent.exercise => _AgentColors(
gradient: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
bg: const Color(0xFFF0FCFF),
border: const Color(0xFFD8F5FC),
iconBg: const Color(0xFFEAFBFF),
accent: const Color(0xFF38BDE8),
verticalGradient: true,
),
_ => _AgentColors(
gradient: [AppColors.primary, AppColors.blueMeasure],
bg: const Color(0xFFF6F3FF),
border: const Color(0xFFE7E0FF),
iconBg: const Color(0xFFF0EDFF),
accent: AppColors.primary,
),
};
}
static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'),
ActiveAgent.diet => (LucideIcons.utensils, '拍饮食', '拍照识别食物热量和营养成分'),
ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'),
ActiveAgent.consultation => (
LucideIcons.messageCircle,
'AI问诊',
'AI智能问诊描述症状获取建议',
),
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告AI 辅助解读'),
ActiveAgent.exercise => (LucideIcons.activity, '运动', '制定运动计划,打卡记录进度'),
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 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 Map && hr['value'] is num ? '${hr['value']}' : null;
final bs = healthData['Glucose'];
final bsText = bs is Map && bs['value'] is num ? '${bs['value']}' : null;
final bo = healthData['SpO2'];
final boText = bo is Map && bo['value'] is num ? '${bo['value']}' : null;
final wt = healthData['Weight'];
final wtText = wt is Map && wt['value'] is num ? '${wt['value']}' : 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.when(
data: (plan) {
if (plan != null) {
final items =
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['scheduledDate']?.toString() == 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'),
),
);
}
}
},
loading: () {
hasExercise = true;
tasks.add(
_taskRow(
context,
Icons.directions_run,
'运动',
trailing: '正在加载运动计划',
status: 'pending',
iconColor: exIconColor,
iconBg: exIconBg,
onTap: () => pushRoute(ref, 'exercisePlan'),
),
);
},
error: (_, _) {
hasExercise = true;
tasks.add(
_taskRow(
context,
Icons.directions_run,
'运动',
trailing: '运动计划加载失败',
status: 'overdue',
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),
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(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 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,
),
],
),
),
),
);
}
}
class _AgentMark extends StatelessWidget {
final IconData icon;
final _AgentColors colors;
const _AgentMark({required this.icon, required this.colors});
@override
Widget build(BuildContext context) {
return Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: colors.gradient,
begin: colors.verticalGradient
? Alignment.topCenter
: Alignment.topLeft,
end: colors.verticalGradient
? Alignment.bottomCenter
: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(colors.verticalGradient ? 18 : 20),
boxShadow: [
BoxShadow(
color: colors.accent.withValues(alpha: 0.18),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Center(child: Icon(icon, size: 32, color: Colors.white)),
);
}
}
class _LinearAccentMark extends StatelessWidget {
final _AgentColors colors;
const _LinearAccentMark({required this.colors});
@override
Widget build(BuildContext context) {
return Container(
width: 86,
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
gradient: LinearGradient(
colors: colors.gradient
.map((color) => color.withValues(alpha: 0.34))
.toList(),
),
),
);
}
}
class _MiniSignalStrip extends StatelessWidget {
final _AgentColors colors;
const _MiniSignalStrip({required this.colors});
@override
Widget build(BuildContext context) {
final bars = [12.0, 20.0, 14.0, 26.0, 16.0, 22.0, 12.0];
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < bars.length; i++) ...[
Container(
width: 4,
height: bars[i],
decoration: BoxDecoration(
color: colors.accent.withValues(alpha: i.isEven ? 0.34 : 0.18),
borderRadius: BorderRadius.circular(99),
),
),
if (i != bars.length - 1) const SizedBox(width: 4),
],
],
);
}
}
typedef _AgentIcon = IconData;
class _AgentColors {
final List<Color> gradient;
final Color bg;
final Color border;
final Color iconBg;
final Color accent;
final bool verticalGradient;
const _AgentColors({
required this.gradient,
required this.bg,
required this.border,
required this.iconBg,
required this.accent,
this.verticalGradient = false,
});
}
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 _ConfirmField {
final String label;
final String value;
const _ConfirmField({
required this.label,
required this.value,
});
}
final _agentActions = <ActiveAgent, List<_AgentAction>>{
ActiveAgent.health: [
_AgentAction(
label: '健康概览',
icon: Icons.trending_up,
isWide: true,
route: 'trend',
),
_AgentAction(
label: '蓝牙录入',
icon: Icons.bluetooth,
isWide: true,
route: 'devices',
),
],
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,
),
),
],
],
),
),
);
}
}