- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast - 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心 - 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles - 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
2175 lines
76 KiB
Dart
2175 lines
76 KiB
Dart
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||
import '../../../core/app_colors.dart';
|
||
import '../../../core/app_design_tokens.dart';
|
||
import '../../../core/app_module_visuals.dart';
|
||
import '../../../core/app_theme.dart';
|
||
import '../../../core/api_client.dart' show baseUrl;
|
||
import '../../../core/navigation_provider.dart';
|
||
import '../../../providers/chat_provider.dart';
|
||
import '../../../providers/data_providers.dart';
|
||
import '../../../widgets/ai_content.dart';
|
||
import '../../../widgets/app_toast.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, ref, 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.sizeOf(context).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: AppTextStyles.chatTitle.copyWith(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w900,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
info.$3,
|
||
style: AppTextStyles.summarySubtitle,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
// ── 插画展示 ──
|
||
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.sizeOf(context).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 = LucideIcons.footprints;
|
||
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
|
||
: LucideIcons.footprints,
|
||
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) {
|
||
AppToast.show(
|
||
context,
|
||
error,
|
||
type: AppToastType.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.all(1.4),
|
||
decoration: BoxDecoration(
|
||
gradient: AppColors.actionOutlineGradient,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(4),
|
||
topRight: Radius.circular(20),
|
||
bottomLeft: Radius.circular(20),
|
||
bottomRight: Radius.circular(20),
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: AppTheme.primary.withAlpha(12),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.cardBackground,
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(3),
|
||
topRight: Radius.circular(18.6),
|
||
bottomLeft: Radius.circular(18.6),
|
||
bottomRight: Radius.circular(18.6),
|
||
),
|
||
),
|
||
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,
|
||
WidgetRef ref,
|
||
ChatMessage msg,
|
||
ChatState? chatState,
|
||
) {
|
||
final isUser = msg.isUser;
|
||
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
||
final localPath = msg.metadata?['localImagePath'] as String?;
|
||
final pdfFileName =
|
||
msg.metadata?['pdfFileName']?.toString() ??
|
||
msg.metadata?['fileName']?.toString();
|
||
final hasImage = imageUrl != null || localPath != null;
|
||
final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
|
||
|
||
return Align(
|
||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
constraints: BoxConstraints(
|
||
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
||
),
|
||
padding: EdgeInsets.all(isUser ? 0 : 1.4),
|
||
decoration: isUser
|
||
? null
|
||
: BoxDecoration(
|
||
gradient: AppColors.actionOutlineGradient,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(4),
|
||
topRight: Radius.circular(20),
|
||
bottomLeft: Radius.circular(20),
|
||
bottomRight: Radius.circular(20),
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: AppTheme.primary.withAlpha(12),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: isUser ? AppTheme.primary : AppColors.cardBackground,
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(isUser ? 18.6 : 3),
|
||
topRight: Radius.circular(isUser ? 3 : 18.6),
|
||
bottomLeft: const Radius.circular(18.6),
|
||
bottomRight: const Radius.circular(18.6),
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 文字内容
|
||
if (isUser)
|
||
SelectableText(
|
||
msg.content,
|
||
style: AppTextStyles.chatBody.copyWith(color: Colors.white),
|
||
)
|
||
else
|
||
AiMarkdownView(
|
||
data: msg.content,
|
||
onTapLink: (text, href, title) =>
|
||
_handleMarkdownLink(context, ref, href),
|
||
),
|
||
|
||
// 图片缩略图(在文字下方)
|
||
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(
|
||
_mediaUrl(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 (hasPdf)
|
||
Container(
|
||
margin: const EdgeInsets.only(top: 8),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 8,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: isUser
|
||
? Colors.white.withValues(alpha: 0.16)
|
||
: AppColors.backgroundSecondary,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.picture_as_pdf_outlined,
|
||
size: 18,
|
||
color: isUser ? Colors.white : AppColors.error,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: Text(
|
||
pdfFileName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: isUser
|
||
? Colors.white
|
||
: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
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(
|
||
'内容由 AI 生成',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 工具方法
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
static void _showFullImage(BuildContext context, String? path) {
|
||
if (path == null) return;
|
||
final resolvedPath = _mediaUrl(path);
|
||
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(resolvedPath, fit: BoxFit.contain)
|
||
: resolvedPath.startsWith('http')
|
||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||
: Image.file(File(resolvedPath), 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),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static String _mediaUrl(String path) {
|
||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||
if (path.startsWith('/uploads/')) return '$baseUrl$path';
|
||
return path;
|
||
}
|
||
|
||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||
// ignore: unused_element
|
||
static String _cleanAiText(String text) {
|
||
var t = text;
|
||
// 移除 $1、$2 等占位符
|
||
t = t.replaceAll(RegExp(r'\$\d+'), '');
|
||
// 移除残留 HTML 标签
|
||
t = t.replaceAll(RegExp(r'<[^>]+>'), '');
|
||
// 行首 # 超过 3 个降为 ###
|
||
t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### ');
|
||
// 压缩多余空行
|
||
t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||
return t.trim();
|
||
}
|
||
|
||
/// 处理 AI 回复里的 markdown 链接点击:
|
||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
||
/// - app://device → 跳到蓝牙设备页
|
||
/// - 其他 app://xxx → 当作 route name 直接跳
|
||
static void _handleMarkdownLink(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
String? href,
|
||
) {
|
||
if (href == null || href.isEmpty) return;
|
||
final uri = Uri.tryParse(href);
|
||
if (uri == null) return;
|
||
|
||
if (uri.scheme == 'app') {
|
||
switch (uri.host) {
|
||
case 'diet':
|
||
ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食');
|
||
break;
|
||
case 'report':
|
||
pushRoute(ref, 'reports');
|
||
break;
|
||
case 'device':
|
||
pushRoute(ref, 'devices');
|
||
break;
|
||
default:
|
||
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
|
||
}
|
||
}
|
||
}
|
||
|
||
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) {
|
||
_AgentColors fromModule(AppModuleVisual visual) => _AgentColors(
|
||
gradient: [visual.gradient.colors.first, visual.gradient.colors.last],
|
||
bg: visual.lightColor,
|
||
border: visual.borderColor,
|
||
iconBg: visual.lightColor,
|
||
accent: visual.color,
|
||
verticalGradient: true,
|
||
);
|
||
|
||
return switch (agent) {
|
||
ActiveAgent.health => fromModule(AppModuleVisuals.health),
|
||
ActiveAgent.diet => fromModule(AppModuleVisuals.diet),
|
||
ActiveAgent.medication => fromModule(AppModuleVisuals.medication),
|
||
ActiveAgent.report => fromModule(AppModuleVisuals.report),
|
||
ActiveAgent.exercise => fromModule(AppModuleVisuals.exercise),
|
||
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,
|
||
),
|
||
_ => _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.footprints, '运动', '制定运动计划,打卡记录进度'),
|
||
_ => (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 abnormals = <String>[];
|
||
if (bp is Map && bp['systolic'] is int) {
|
||
final s = bp['systolic'] as int;
|
||
final d = bp['diastolic'] is int ? bp['diastolic'] as int : 0;
|
||
if (s >= 140 || d >= 90) abnormals.add('血压 $s/$d 偏高');
|
||
}
|
||
if (hr is Map && hr['value'] is num) {
|
||
final v = (hr['value'] as num).toDouble();
|
||
if (v > 100) {
|
||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
||
} else if (v < 60) {
|
||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
||
}
|
||
}
|
||
if (bs is Map && bs['value'] is num) {
|
||
final v = (bs['value'] as num).toDouble();
|
||
if (v > 6.1) abnormals.add('血糖 ${v.toStringAsFixed(1)} 偏高');
|
||
}
|
||
if (bo is Map && bo['value'] is num) {
|
||
final v = (bo['value'] as num).toDouble();
|
||
if (v < 95) abnormals.add('血氧 ${v.toStringAsFixed(0)}% 偏低');
|
||
}
|
||
final hasAbnormal = abnormals.isNotEmpty;
|
||
|
||
// ── 1. 健康指标 ──
|
||
// 没记录则整行不显示(避免每天唠叨提醒)
|
||
final healthIconColor = AppModuleVisuals.health.color;
|
||
final healthIconBg = AppModuleVisuals.health.lightColor;
|
||
if (allNull) {
|
||
// 不显示
|
||
} else if (hasAbnormal) {
|
||
final trailing = abnormals.length == 1
|
||
? abnormals.first
|
||
: '${abnormals.length} 项指标异常';
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
LucideIcons.triangleAlert,
|
||
'健康指标',
|
||
trailing: trailing,
|
||
status: 'warning',
|
||
iconColor: healthIconColor,
|
||
iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend'),
|
||
),
|
||
);
|
||
} else {
|
||
// 全部正常
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
LucideIcons.circleCheck,
|
||
'健康指标',
|
||
trailing: '指标正常',
|
||
status: 'done',
|
||
iconColor: healthIconColor,
|
||
iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend'),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 2. 运动 ──
|
||
final exIconColor = AppModuleVisuals.exercise.color;
|
||
final exIconBg = AppModuleVisuals.exercise.lightColor;
|
||
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,
|
||
LucideIcons.footprints,
|
||
'$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,
|
||
LucideIcons.footprints,
|
||
'运动',
|
||
trailing: '正在加载运动计划',
|
||
status: 'pending',
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
},
|
||
error: (_, _) {
|
||
hasExercise = true;
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
LucideIcons.footprints,
|
||
'运动',
|
||
trailing: '运动计划加载失败',
|
||
status: 'overdue',
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
if (!hasExercise) {
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
LucideIcons.footprints,
|
||
'运动',
|
||
trailing: '暂无今日运动计划',
|
||
status: 'pending',
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 3. 用药打卡 ──
|
||
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
|
||
final medIconColor = AppModuleVisuals.medication.color;
|
||
final medIconBg = AppModuleVisuals.medication.lightColor;
|
||
reminders.whenOrNull(
|
||
data: (meds) {
|
||
final overdueMeds = meds
|
||
.where((m) => m['status'] == 'overdue')
|
||
.toList();
|
||
if (overdueMeds.isEmpty) {
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
LucideIcons.pill,
|
||
'用药',
|
||
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||
status: 'done',
|
||
iconColor: medIconColor,
|
||
iconBg: medIconBg,
|
||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 按过期时长排序——先把最严重的放前面
|
||
final now = DateTime.now();
|
||
final withDelta = overdueMeds.map((m) {
|
||
final timeStr = m['scheduledTime']?.toString() ?? '';
|
||
final parts = timeStr.split(':');
|
||
DateTime? scheduled;
|
||
if (parts.length >= 2) {
|
||
final h = int.tryParse(parts[0]);
|
||
final mn = int.tryParse(parts[1]);
|
||
if (h != null && mn != null) {
|
||
scheduled = DateTime(now.year, now.month, now.day, h, mn);
|
||
}
|
||
}
|
||
final overdueHours = scheduled == null
|
||
? 0
|
||
: now.difference(scheduled).inMinutes / 60.0;
|
||
return (m, overdueHours);
|
||
}).toList()..sort((a, b) => b.$2.compareTo(a.$2));
|
||
|
||
// 取最严重那一项作为代表,其他用"等"省略
|
||
final first = withDelta.first;
|
||
final firstName = first.$1['name']?.toString() ?? '药品';
|
||
final firstTime = first.$1['scheduledTime']?.toString() ?? '';
|
||
final firstHours = first.$2;
|
||
|
||
// 过期 < 1h 提醒服药;1-4h 可补服(红色);>4h 已错过窗口(灰色提示但不强警告)
|
||
final String trailing;
|
||
final String status;
|
||
if (firstHours < 1) {
|
||
trailing = '$firstTime 该服药了';
|
||
status = 'pending';
|
||
} else if (firstHours < 4) {
|
||
trailing = '$firstTime 已漏服 ${firstHours.toStringAsFixed(0)} 小时,可补服';
|
||
status = 'overdue';
|
||
} else {
|
||
trailing = '$firstTime 已错过,请等下次按时服用';
|
||
status = 'overdue';
|
||
}
|
||
|
||
final title = overdueMeds.length == 1
|
||
? firstName
|
||
: '$firstName 等 ${overdueMeds.length} 种';
|
||
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
LucideIcons.pill,
|
||
title,
|
||
trailing: trailing,
|
||
status: status,
|
||
iconColor: medIconColor,
|
||
iconBg: medIconBg,
|
||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|