2347 lines
80 KiB
Dart
2347 lines
80 KiB
Dart
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../../core/app_colors.dart';
|
||
import '../../../core/app_theme.dart';
|
||
import '../../../core/navigation_provider.dart';
|
||
import '../../../providers/auth_provider.dart';
|
||
import '../../../providers/chat_provider.dart';
|
||
import '../../../providers/data_providers.dart';
|
||
|
||
/// 卡片入场动画包装(从下往上滑入 + 淡入)
|
||
class _AnimatedCardEntry extends StatefulWidget {
|
||
final Widget child;
|
||
const _AnimatedCardEntry({required this.child});
|
||
|
||
@override
|
||
State<_AnimatedCardEntry> createState() => _AnimatedCardEntryState();
|
||
}
|
||
|
||
class _AnimatedCardEntryState extends State<_AnimatedCardEntry>
|
||
with SingleTickerProviderStateMixin {
|
||
late final _ctrl = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 600),
|
||
);
|
||
late final _slide = Tween<Offset>(
|
||
begin: const Offset(0, 0.12),
|
||
end: Offset.zero,
|
||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||
late final _fade = Tween<double>(
|
||
begin: 0.0,
|
||
end: 1.0,
|
||
).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl.forward();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FadeTransition(
|
||
opacity: _fade,
|
||
child: SlideTransition(position: _slide, child: widget.child),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 对话消息列表
|
||
class ChatMessagesView extends ConsumerWidget {
|
||
final ScrollController scrollCtrl;
|
||
final List<ChatMessage> messages;
|
||
|
||
const ChatMessagesView({
|
||
super.key,
|
||
required this.scrollCtrl,
|
||
required this.messages,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final chatState = ref.watch(chatProvider);
|
||
|
||
if (messages.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 80,
|
||
height: 80,
|
||
decoration: BoxDecoration(
|
||
color: AppTheme.primaryLight,
|
||
borderRadius: BorderRadius.circular(40),
|
||
),
|
||
child: const Icon(
|
||
Icons.health_and_safety,
|
||
size: 40,
|
||
color: AppTheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'开始和 AI 健康管家对话吧',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'记录健康数据,获取专业建议',
|
||
style: TextStyle(fontSize: 17, color: AppColors.textHint),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return ListView.builder(
|
||
controller: scrollCtrl,
|
||
reverse: false,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
itemCount: messages.length,
|
||
itemBuilder: (context, index) {
|
||
final msg = messages[index];
|
||
return _buildMessageContent(context, ref, msg, chatState);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ─── 消息分发 ─────────────────────────────────────────────
|
||
|
||
Widget _buildMessageContent(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
ChatMessage msg,
|
||
ChatState chatState,
|
||
) {
|
||
switch (msg.type) {
|
||
case MessageType.agentWelcome:
|
||
final storedAgent = _parseAgentFromName(
|
||
msg.metadata?['agent'] as String?,
|
||
);
|
||
return _buildAgentWelcomeCard(context, ref, msg, storedAgent);
|
||
case MessageType.taskCard:
|
||
return _buildTaskCardInChat(context, ref);
|
||
case MessageType.dataConfirm:
|
||
return _buildDataConfirmCard(context, ref, msg);
|
||
default:
|
||
if (!msg.isUser &&
|
||
chatState.isStreaming &&
|
||
msg.content.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,
|
||
),
|
||
|
||
// ── 医生选择区(问诊专用)──
|
||
if (agent == ActiveAgent.consultation) ...[
|
||
const SizedBox(height: 14),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: _buildDoctorCards(ref, agentColors),
|
||
),
|
||
],
|
||
|
||
if (actions.isNotEmpty) ...[
|
||
const SizedBox(height: 14),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: 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',
|
||
};
|
||
}
|
||
|
||
Widget _buildDoctorCards(WidgetRef ref, _AgentColors colors) {
|
||
const doctors = [
|
||
{
|
||
'name': '张明',
|
||
'title': '主任医师',
|
||
'dept': '心脏康复科',
|
||
'desc': '冠心病、高血压术后管理',
|
||
'id': '468b82e2-d95a-4436-bff6-a50eecf99a66',
|
||
},
|
||
{
|
||
'name': '李芳',
|
||
'title': '副主任医师',
|
||
'dept': '营养科',
|
||
'desc': '糖尿病、甲状腺疾病管理',
|
||
'id': 'd4148733-b538-4398-af17-0c7592fc0c2d',
|
||
},
|
||
{
|
||
'name': '王建国',
|
||
'title': '主任医师',
|
||
'dept': '心血管内科',
|
||
'desc': '术后营养指导、饮食方案制定',
|
||
'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3',
|
||
},
|
||
];
|
||
return Row(
|
||
children: [
|
||
for (var i = 0; i < doctors.length; i++) ...[
|
||
if (i > 0) const SizedBox(width: 8),
|
||
Expanded(child: _doctorCard(doctors[i], ref, colors)),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _doctorCard(
|
||
Map<String, String> doc,
|
||
WidgetRef ref,
|
||
_AgentColors colors,
|
||
) {
|
||
return InkWell(
|
||
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: colors.border),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 22,
|
||
backgroundColor: colors.iconBg,
|
||
child: Text(
|
||
doc['name']![0],
|
||
style: TextStyle(
|
||
fontSize: 21,
|
||
fontWeight: FontWeight.w600,
|
||
color: colors.accent,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
doc['name']!,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
Text(
|
||
doc['title']!,
|
||
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: colors.iconBg,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
doc['dept']!,
|
||
style: TextStyle(fontSize: 13, color: colors.accent),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
doc['desc']!,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textSecondary,
|
||
height: 1.3,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 2. DataConfirmCard — 统一确认卡片(紫白蓝风格)
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildDataConfirmCard(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
ChatMessage msg,
|
||
) {
|
||
final meta = msg.metadata ?? <String, dynamic>{};
|
||
final backendType = meta['type'] as String? ?? '';
|
||
final screenWidth = MediaQuery.of(context).size.width;
|
||
|
||
// 识别是哪种数据类型
|
||
final isMedication =
|
||
backendType == 'medication' ||
|
||
(meta['name'] != null &&
|
||
meta['name'].toString().isNotEmpty &&
|
||
backendType != 'exercise');
|
||
final isExercise = backendType == 'exercise';
|
||
final isHealth = !isMedication && !isExercise;
|
||
|
||
// 根据类型获取显示字段
|
||
String title = '数据确认';
|
||
IconData titleIcon = Icons.assignment;
|
||
String mainLabel = '';
|
||
String mainValue = '';
|
||
String mainUnit = '';
|
||
bool abnormal = meta['abnormal'] as bool? ?? false;
|
||
String recordTime = meta['recordTime'] as String? ?? '';
|
||
|
||
List<_ConfirmField> fields = [];
|
||
|
||
if (isMedication) {
|
||
// 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息
|
||
title = '药品录入确认';
|
||
titleIcon = Icons.medication_liquid_outlined;
|
||
mainLabel = meta['name'] as String? ?? '';
|
||
mainValue = meta['dosage'] as String? ?? '';
|
||
mainUnit = '';
|
||
|
||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||
final time = meta['time'] as String? ?? '';
|
||
if (time.isNotEmpty) {
|
||
final timeValue = meta['服药时间'] as String? ?? time;
|
||
fields.add(
|
||
_ConfirmField(label: '服药时间', value: timeValue, editable: true),
|
||
);
|
||
}
|
||
final frequency = meta['frequency'] as String? ?? '';
|
||
if (frequency.isNotEmpty) {
|
||
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
|
||
fields.add(
|
||
_ConfirmField(label: '频率', value: freqValue, editable: true),
|
||
);
|
||
}
|
||
final duration = meta['duration_days'] as int?;
|
||
if (duration != null && duration > 0) {
|
||
final durationValue = meta['服用天数'] as String? ?? '$duration 天';
|
||
fields.add(
|
||
_ConfirmField(label: '服用天数', value: durationValue, editable: true),
|
||
);
|
||
}
|
||
} else if (isExercise) {
|
||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||
title = '运动计划确认';
|
||
titleIcon = Icons.directions_run_outlined;
|
||
final exName =
|
||
(meta['value'] ??
|
||
meta['name'] ??
|
||
meta['exerciseType'] ??
|
||
meta['运动类型'] ??
|
||
'')
|
||
.toString();
|
||
final exUnitRaw =
|
||
(meta['unit'] ?? meta['duration_unit'] ?? meta['durationUnit'] ?? '')
|
||
.toString();
|
||
final exDaysRaw = meta['durationDays'] ?? meta['day_count'];
|
||
final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0;
|
||
|
||
// 解析 "每天30分钟" → 频率=每天, 时长=30分钟
|
||
final freqMatch = RegExp(r'^(每天|每周|每月)').firstMatch(exUnitRaw);
|
||
final durMatch = RegExp(r'(\d+分钟)').firstMatch(exUnitRaw);
|
||
final freq = freqMatch?.group(1) ?? '';
|
||
final dur = durMatch?.group(1) ?? exUnitRaw;
|
||
|
||
mainLabel = '运动计划';
|
||
mainValue = exName.isNotEmpty ? exName : '运动计划';
|
||
mainUnit = '';
|
||
|
||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||
if (dur.isNotEmpty) {
|
||
fields.add(_ConfirmField(label: '运动时长', value: dur, editable: true));
|
||
}
|
||
if (freq.isNotEmpty) {
|
||
fields.add(_ConfirmField(label: '频率', value: freq));
|
||
}
|
||
if (exDays > 0) {
|
||
fields.add(_ConfirmField(label: '计划天数', value: '${exDays}天'));
|
||
}
|
||
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
|
||
if (intensity.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(label: '运动强度', value: intensity, editable: true),
|
||
);
|
||
}
|
||
final calories =
|
||
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
|
||
.toString();
|
||
if (calories.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(label: '消耗热量', value: '$calories kcal', editable: true),
|
||
);
|
||
}
|
||
} else {
|
||
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
|
||
title = '健康数据确认';
|
||
titleIcon = Icons.monitor_heart_outlined;
|
||
mainLabel = _getMetricName(backendType);
|
||
mainValue = meta['value'] as String? ?? '';
|
||
mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType);
|
||
|
||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||
final timeValue = meta['记录时间'] as String? ?? recordTime;
|
||
fields.add(
|
||
_ConfirmField(label: '记录时间', value: timeValue, editable: true),
|
||
);
|
||
final note = meta['note'] as String? ?? '';
|
||
if (note.isNotEmpty) {
|
||
final noteValue = meta['备注'] as String? ?? note;
|
||
fields.add(
|
||
_ConfirmField(label: '备注', value: noteValue, editable: true),
|
||
);
|
||
}
|
||
}
|
||
|
||
return _AnimatedCardEntry(
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 16),
|
||
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
|
||
decoration: BoxDecoration(
|
||
color: 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], msg, ref),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
|
||
// ── 确认按钮(白色底+渐变边框) ──
|
||
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: () {
|
||
ref
|
||
.read(chatProvider.notifier)
|
||
.confirmMessage(msg.id);
|
||
},
|
||
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, ChatMessage msg, WidgetRef ref) {
|
||
final isEditing = field.label == msg.metadata?['_editingField'];
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 80,
|
||
child: Text(
|
||
field.label,
|
||
style: const TextStyle(fontSize: 15, color: AppColors.textHint),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: !field.editable
|
||
? Text(
|
||
field.value.isNotEmpty ? field.value : '未设置',
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
)
|
||
: isEditing
|
||
? _buildEditableTextField(field, msg, ref)
|
||
: InkWell(
|
||
onTap: () => ref
|
||
.read(chatProvider.notifier)
|
||
.startEditingField(msg.id, field.label),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
field.value.isNotEmpty ? field.value : '未设置',
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
Icon(
|
||
Icons.edit_outlined,
|
||
size: 18,
|
||
color: AppColors.border,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildEditableTextField(
|
||
_ConfirmField field,
|
||
ChatMessage msg,
|
||
WidgetRef ref,
|
||
) {
|
||
return _EditFieldCell(
|
||
key: ValueKey('editing_${msg.id}_${field.label}'),
|
||
initialValue: field.value,
|
||
onDone: (value) {
|
||
ref
|
||
.read(chatProvider.notifier)
|
||
.finishEditingField(msg.id, field.label, value);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _editField(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
|
||
// 字段可点击修改的入口,后续可接入 showDialog 实现
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 3. ThinkingBubble — 思考动画
|
||
// ═══════════════════════════════════════════════════════════
|
||
Widget _buildThinkingBubble(BuildContext context, String? thinkingText) {
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardBackground,
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(4),
|
||
topRight: Radius.circular(20),
|
||
bottomLeft: Radius.circular(20),
|
||
bottomRight: Radius.circular(20),
|
||
),
|
||
border: Border.all(color: AppColors.border, width: 1.5),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: AppTheme.primary.withAlpha(12),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 26,
|
||
height: 26,
|
||
padding: const EdgeInsets.all(5),
|
||
decoration: BoxDecoration(
|
||
color: AppTheme.primaryLight,
|
||
borderRadius: BorderRadius.circular(13),
|
||
),
|
||
child: const CircularProgressIndicator(
|
||
strokeWidth: 2.2,
|
||
color: AppTheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
|
||
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildTextBubble(
|
||
BuildContext context,
|
||
ChatMessage msg,
|
||
ChatState? chatState,
|
||
) {
|
||
final isUser = msg.isUser;
|
||
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
||
final localPath = msg.metadata?['localImagePath'] as String?;
|
||
final hasImage = imageUrl != null || localPath != null;
|
||
|
||
return Align(
|
||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
constraints: BoxConstraints(
|
||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||
),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: isUser ? 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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 公共组件:通用按钮
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _cardFilledBtn(String label, IconData icon, {VoidCallback? onTap}) {
|
||
return ElevatedButton(
|
||
onPressed: onTap ?? () {},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: AppTheme.primary,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, size: 19),
|
||
const SizedBox(width: 5),
|
||
Text(
|
||
label,
|
||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _cardOutlineBtn(String label, IconData icon, {VoidCallback? onTap}) {
|
||
return OutlinedButton(
|
||
onPressed: onTap ?? () {},
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: AppTheme.primary,
|
||
side: const BorderSide(color: AppTheme.primary, width: 1.2),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, size: 18),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
label,
|
||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 工具方法
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
static void _showFullImage(BuildContext context, String? path) {
|
||
if (path == null) return;
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => Dialog(
|
||
backgroundColor: Colors.black,
|
||
insetPadding: const EdgeInsets.all(24),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: InteractiveViewer(
|
||
child: path.startsWith('http')
|
||
? Image.network(path, fit: BoxFit.contain)
|
||
: Image.file(File(path), fit: BoxFit.contain),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: 8,
|
||
right: 8,
|
||
child: GestureDetector(
|
||
onTap: () => Navigator.pop(ctx),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(6),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white54,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(Icons.close, size: 21),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||
static String _cleanAiText(String text) {
|
||
return text
|
||
.replaceAll('\$1', '') // AI 偶尔输出 $1 占位符(正则残留)
|
||
.replaceAll(RegExp(r'\n{3,}'), '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
String _freqLabel(String freq) {
|
||
switch (freq.toLowerCase()) {
|
||
case 'daily':
|
||
return '每天';
|
||
case 'everyotherday':
|
||
return '隔天';
|
||
case 'weekly':
|
||
return '每周';
|
||
default:
|
||
return freq;
|
||
}
|
||
}
|
||
|
||
String _formatTime(DateTime dt) {
|
||
final now = DateTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
final thatDay = DateTime(dt.year, dt.month, dt.day);
|
||
if (thatDay == today) {
|
||
return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
if (thatDay == today.subtract(const Duration(days: 1))) {
|
||
return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
String _getMetricUnit(String type) {
|
||
switch (type.toLowerCase()) {
|
||
case 'blood_pressure':
|
||
return 'mmHg';
|
||
case 'heart_rate':
|
||
return 'bpm';
|
||
case 'glucose':
|
||
return 'mmol/L';
|
||
case 'spo2':
|
||
return '%';
|
||
case 'weight':
|
||
return 'kg';
|
||
case 'exercise':
|
||
return '分钟/天';
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
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 void _medicationCheckIn(WidgetRef ref, BuildContext context) async {
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final reminders = await ref.read(medicationReminderProvider.future);
|
||
if (reminders.isEmpty) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('暂无待服药记录'),
|
||
backgroundColor: AppColors.warning,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
for (final m in reminders) {
|
||
final id = m['id']?.toString() ?? '';
|
||
final time = m['scheduledTime']?.toString() ?? '';
|
||
if (id.isEmpty) continue;
|
||
await api.post(
|
||
'/api/medications/$id/confirm-dose',
|
||
data: {'scheduledTime': time, 'status': 'taken'},
|
||
);
|
||
}
|
||
ref.invalidate(medicationReminderProvider);
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('打卡成功 已记录 ${reminders.length} 项服药'),
|
||
backgroundColor: AppTheme.success,
|
||
),
|
||
);
|
||
} catch (e) {
|
||
if (!context.mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
|
||
static _AgentColors _agentColors(ActiveAgent agent) {
|
||
return switch (agent) {
|
||
ActiveAgent.consultation => _AgentColors(
|
||
gradient: [AppColors.sageAccent, AppColors.primary],
|
||
bg: const Color(0xFFF2FBF9),
|
||
border: const Color(0xFFD9F0EA),
|
||
iconBg: const Color(0xFFE8F8F4),
|
||
accent: const Color(0xFF4F9E90),
|
||
),
|
||
ActiveAgent.health => _AgentColors(
|
||
gradient: [AppColors.meadowAccent, AppColors.auraDeepIndigo],
|
||
bg: const Color(0xFFF3FBF4),
|
||
border: const Color(0xFFDFF2E4),
|
||
iconBg: const Color(0xFFEFF9F0),
|
||
accent: const Color(0xFF4EA65B),
|
||
),
|
||
ActiveAgent.diet => _AgentColors(
|
||
gradient: [AppColors.peachAccent, AppColors.auraOrchid],
|
||
bg: const Color(0xFFFFF7F0),
|
||
border: const Color(0xFFFFE8D4),
|
||
iconBg: const Color(0xFFFFF0E4),
|
||
accent: const Color(0xFFE26A64),
|
||
),
|
||
ActiveAgent.medication => _AgentColors(
|
||
gradient: [AppColors.auraLavender, AppColors.blueMeasure],
|
||
bg: const Color(0xFFF4F6FF),
|
||
border: const Color(0xFFDDE6FF),
|
||
iconBg: const Color(0xFFEFF3FF),
|
||
accent: AppColors.primary,
|
||
),
|
||
ActiveAgent.report => _AgentColors(
|
||
gradient: [AppColors.primary, AppColors.roseAccent],
|
||
bg: const Color(0xFFF8F5FF),
|
||
border: const Color(0xFFE9E0FF),
|
||
iconBg: const Color(0xFFF2EDFF),
|
||
accent: AppColors.primary,
|
||
),
|
||
ActiveAgent.exercise => _AgentColors(
|
||
gradient: [AppColors.sageAccent, AppColors.auraDeepIndigo],
|
||
bg: const Color(0xFFF0FBFD),
|
||
border: const Color(0xFFD6EEF2),
|
||
iconBg: const Color(0xFFE8F7FA),
|
||
accent: const Color(0xFF2B8EA0),
|
||
),
|
||
_ => _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 => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'),
|
||
ActiveAgent.diet => (Icons.restaurant, '拍饮食', '拍照识别食物热量和营养成分'),
|
||
ActiveAgent.medication => (Icons.medication, '药管家', '管理药品、提醒服药、追踪用量'),
|
||
ActiveAgent.consultation => (
|
||
Icons.local_hospital,
|
||
'问诊',
|
||
'在线咨询医生,描述症状获取建议',
|
||
),
|
||
ActiveAgent.report => (Icons.assignment, '报告分析', '上传体检报告,AI 辅助解读'),
|
||
ActiveAgent.exercise => (Icons.directions_run, '运动', '制定运动计划,打卡记录进度'),
|
||
_ => (Icons.forum_outlined, 'AI 助手', '您的智能健康管家'),
|
||
};
|
||
}
|
||
|
||
static String _agentTip(ActiveAgent agent) => switch (agent) {
|
||
ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"',
|
||
ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"',
|
||
ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"',
|
||
ActiveAgent.consultation => '描述您的症状和不适感,如"最近总是头晕胸闷"',
|
||
ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读',
|
||
ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"',
|
||
_ => '直接描述您的需求,AI 会自动为您记录和分析',
|
||
};
|
||
|
||
static ActiveAgent _parseAgentFromName(String? name) {
|
||
switch (name) {
|
||
case 'consultation':
|
||
return ActiveAgent.consultation;
|
||
case 'health':
|
||
return ActiveAgent.health;
|
||
case 'diet':
|
||
return ActiveAgent.diet;
|
||
case 'medication':
|
||
return ActiveAgent.medication;
|
||
case 'report':
|
||
return ActiveAgent.report;
|
||
case 'exercise':
|
||
return ActiveAgent.exercise;
|
||
default:
|
||
return ActiveAgent.default_;
|
||
}
|
||
}
|
||
|
||
Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) {
|
||
final health = ref.watch(latestHealthProvider);
|
||
final reminders = ref.watch(medicationReminderProvider);
|
||
return health.when(
|
||
data: (data) => _taskCardBubble(context, ref, data, reminders),
|
||
loading: () =>
|
||
_taskCardBubble(context, ref, {}, const AsyncValue.loading()),
|
||
error: (_, e) =>
|
||
_taskCardBubble(context, ref, {}, const AsyncValue.loading()),
|
||
);
|
||
}
|
||
|
||
Widget _taskCardBubble(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
Map<String, dynamic> healthData,
|
||
AsyncValue<List<Map<String, dynamic>>> reminders,
|
||
) {
|
||
final now = DateTime.now();
|
||
final tasks = <Widget>[];
|
||
|
||
// ── 解析健康指标 ──
|
||
final bp = healthData['BloodPressure'];
|
||
final bpText = bp is Map
|
||
? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'
|
||
: null;
|
||
final hr = healthData['HeartRate'];
|
||
final hrText = hr is int ? '$hr' : null;
|
||
final bs = healthData['BloodSugar'];
|
||
final bsText = bs is num ? '$bs' : null;
|
||
final bo = healthData['BloodOxygen'];
|
||
final boText = bo is num ? '$bo' : null;
|
||
final wt = healthData['Weight'];
|
||
final wtText = wt is num ? '$wt' : null;
|
||
final allNull =
|
||
bpText == null &&
|
||
hrText == null &&
|
||
bsText == null &&
|
||
boText == null &&
|
||
wtText == null;
|
||
|
||
// 异常检测
|
||
final bpAbnormal =
|
||
bp is Map && bp['systolic'] is int && (bp['systolic'] as int) >= 140;
|
||
final hasAbnormal = bpAbnormal;
|
||
|
||
// ── 1. 健康指标 ──
|
||
const healthIconColor = Color(0xFF3B82F6);
|
||
const healthIconBg = Color(0xFFDBEAFE);
|
||
if (allNull) {
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.monitor_heart_outlined,
|
||
'健康指标',
|
||
trailing: '今日暂无记录,点击录入',
|
||
status: 'pending',
|
||
iconColor: healthIconColor,
|
||
iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend'),
|
||
),
|
||
);
|
||
} else if (hasAbnormal) {
|
||
// 有异常时显示异常指标
|
||
final abnormalParts = <String>[];
|
||
if (bpAbnormal)
|
||
abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高');
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.warning_amber_rounded,
|
||
'健康指标',
|
||
trailing: abnormalParts.join(' · '),
|
||
status: 'warning',
|
||
iconColor: healthIconColor,
|
||
iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend'),
|
||
),
|
||
);
|
||
} else {
|
||
// 全部正常
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.check_circle,
|
||
'健康指标',
|
||
trailing: '指标正常',
|
||
status: 'done',
|
||
iconColor: healthIconColor,
|
||
iconBg: healthIconBg,
|
||
onTap: () => pushRoute(ref, 'trend'),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 2. 运动 ──
|
||
const exIconColor = Color(0xFFF59E0B);
|
||
const exIconBg = Color(0xFFFEF3C7);
|
||
final exercisePlan = ref.watch(currentExercisePlanProvider);
|
||
var hasExercise = false;
|
||
exercisePlan.whenOrNull(
|
||
data: (plan) {
|
||
if (plan != null) {
|
||
final items =
|
||
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final today = now.weekday % 7;
|
||
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||
(i) => i?['dayOfWeek'] == today,
|
||
orElse: () => null,
|
||
);
|
||
if (todayItem != null && todayItem['isRestDay'] != true) {
|
||
hasExercise = true;
|
||
final done = todayItem['isCompleted'] == true;
|
||
final name = items.isNotEmpty
|
||
? (items.first['exerciseType']?.toString() ?? '运动')
|
||
: '运动';
|
||
final dur = items.isNotEmpty
|
||
? (items.first['durationMinutes']?.toString() ?? '30')
|
||
: '30';
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.directions_run,
|
||
'$name $dur分钟',
|
||
status: done
|
||
? 'done'
|
||
: (now.hour >= 18 ? 'overdue' : 'pending'),
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
if (!hasExercise) {
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.directions_run,
|
||
'运动',
|
||
trailing: '暂无今日运动计划',
|
||
status: 'pending',
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 3. 用药打卡 ──
|
||
const medIconColor = Color(0xFFEC4899);
|
||
const medIconBg = Color(0xFFFCE7F3);
|
||
reminders.whenOrNull(
|
||
data: (meds) {
|
||
if (meds.isNotEmpty) {
|
||
final overdueMeds = meds
|
||
.where((m) => m['status'] == 'overdue')
|
||
.toList();
|
||
final upcomingMeds = meds
|
||
.where((m) => m['status'] == 'upcoming')
|
||
.toList();
|
||
final takenMeds = meds.where((m) => m['status'] == 'taken').toList();
|
||
// 未服/过期的在前面
|
||
for (final m in [...overdueMeds, ...upcomingMeds]) {
|
||
final name = m['name']?.toString() ?? '药品';
|
||
final time = m['scheduledTime']?.toString() ?? '';
|
||
final isOverdue = m['status'] == 'overdue';
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.medication_rounded,
|
||
name,
|
||
trailing: '${isOverdue ? "❗过期" : "⏳待服"} $time',
|
||
status: isOverdue ? 'overdue' : 'pending',
|
||
iconColor: medIconColor,
|
||
iconBg: medIconBg,
|
||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||
),
|
||
);
|
||
}
|
||
// 已服的合并显示
|
||
if (takenMeds.isNotEmpty) {
|
||
final names = takenMeds
|
||
.map((m) => m['name']?.toString() ?? '药品')
|
||
.join('、');
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.check_circle,
|
||
names,
|
||
trailing: '已服用 (${takenMeds.length}项)',
|
||
status: 'done',
|
||
iconColor: medIconColor,
|
||
iconBg: medIconBg,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
final hasMeds =
|
||
reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty;
|
||
if (!hasMeds) {
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
Icons.medication_rounded,
|
||
'用药打卡',
|
||
trailing: '暂无用药提醒',
|
||
status: 'pending',
|
||
iconColor: medIconColor,
|
||
iconBg: medIconBg,
|
||
onTap: () => pushRoute(ref, 'medications'),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(28),
|
||
border: Border.all(color: Color(0xFFE2E8F0), width: 2),
|
||
boxShadow: AppColors.cardShadow,
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
||
decoration: const BoxDecoration(
|
||
gradient: AppColors.drawerGradient,
|
||
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: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: colors.accent.withValues(alpha: 0.24),
|
||
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;
|
||
const _AgentColors({
|
||
required this.gradient,
|
||
required this.bg,
|
||
required this.border,
|
||
required this.iconBg,
|
||
required this.accent,
|
||
});
|
||
}
|
||
|
||
class _AgentAction {
|
||
final String label;
|
||
final IconData icon;
|
||
final bool isWide;
|
||
final String? route;
|
||
final String? action;
|
||
|
||
const _AgentAction({
|
||
required this.label,
|
||
required this.icon,
|
||
this.isWide = false,
|
||
this.route,
|
||
this.action,
|
||
});
|
||
}
|
||
|
||
class _ConfirmField {
|
||
final String label;
|
||
final String value;
|
||
final IconData icon;
|
||
final bool editable;
|
||
const _ConfirmField({
|
||
required this.label,
|
||
required this.value,
|
||
this.icon = Icons.info_outline,
|
||
this.editable = false,
|
||
});
|
||
}
|
||
|
||
final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||
ActiveAgent.health: [
|
||
_AgentAction(
|
||
label: '健康概览',
|
||
icon: Icons.trending_up,
|
||
isWide: true,
|
||
route: 'trend',
|
||
),
|
||
_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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EditFieldCell extends StatefulWidget {
|
||
final String initialValue;
|
||
final ValueChanged<String> onDone;
|
||
const _EditFieldCell({
|
||
super.key,
|
||
required this.initialValue,
|
||
required this.onDone,
|
||
});
|
||
|
||
@override
|
||
State<_EditFieldCell> createState() => _EditFieldCellState();
|
||
}
|
||
|
||
class _EditFieldCellState extends State<_EditFieldCell> {
|
||
late final TextEditingController _ctrl;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = TextEditingController(text: widget.initialValue);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => TextField(
|
||
controller: _ctrl,
|
||
autofocus: true,
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: Color(0xFF1E293B),
|
||
),
|
||
decoration: InputDecoration(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: BorderSide(color: const Color(0xFF6366F1).withAlpha(80)),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(color: Color(0xFF6366F1), width: 1.5),
|
||
),
|
||
),
|
||
onSubmitted: widget.onDone,
|
||
onTapOutside: (_) => widget.onDone(_ctrl.text),
|
||
);
|
||
}
|