- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router) - 新增 AI 同意门控(用户需同意后才能使用 AI 功能) - 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord) - 新增 AI 意图路由(ai_intent_router) - 新增医疗引用知识库(MedicalCitationKnowledge) - 重构 prompt_manager 和 ai_chat_endpoints - 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI - 新增 agent 插画和趋势指标图标资源 - 删除 HANDOFF-2026-07-17.md
2268 lines
78 KiB
Dart
2268 lines
78 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/navigation_provider.dart';
|
||
import '../../../providers/chat_provider.dart';
|
||
import '../../../providers/data_providers.dart';
|
||
import '../../../widgets/ai_content.dart';
|
||
import '../../../widgets/app_toast.dart';
|
||
import '../../../widgets/authenticated_network_image.dart';
|
||
|
||
ChatMessage messageAtDisplayIndex(List<ChatMessage> messages, int index) =>
|
||
messages[index];
|
||
|
||
/// 对话消息列表
|
||
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 streaming = ref.watch(
|
||
chatProvider.select((state) => (state.isStreaming, state.thinkingText)),
|
||
);
|
||
|
||
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,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
cacheExtent: 180,
|
||
itemCount: messages.length,
|
||
itemBuilder: (context, index) {
|
||
final msg = messageAtDisplayIndex(messages, index);
|
||
return KeyedSubtree(
|
||
key: ValueKey('chat-message-${msg.id}'),
|
||
child: _buildMessageContent(
|
||
context,
|
||
ref,
|
||
msg,
|
||
streaming.$1,
|
||
streaming.$2 ?? '',
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ─── 消息分发 ─────────────────────────────────────────────
|
||
|
||
Widget _buildMessageContent(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
ChatMessage msg,
|
||
bool isStreaming,
|
||
String thinkingText,
|
||
) {
|
||
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 && isStreaming && msg.content.trim().isEmpty) {
|
||
return _buildThinkingBubble(context, thinkingText);
|
||
}
|
||
return _buildTextBubble(context, ref, msg);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 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 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),
|
||
border: Border.all(color: const Color(0xFFE8ECF3)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: const Color(0xFF101828).withValues(alpha: 0.10),
|
||
blurRadius: 24,
|
||
offset: const Offset(0, 12),
|
||
),
|
||
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(22, 22, 22, 8),
|
||
color: Colors.white,
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_AgentMark(icon: info.$1, colors: agentColors),
|
||
const SizedBox(width: 18),
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
info.$2,
|
||
style: AppTextStyles.chatTitle.copyWith(
|
||
fontSize: 28,
|
||
height: 1.1,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
info.$3,
|
||
style: AppTextStyles.summarySubtitle.copyWith(
|
||
fontSize: 16,
|
||
height: 1.45,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 2),
|
||
child: SizedBox(
|
||
height: 214,
|
||
width: double.infinity,
|
||
child: Image.asset(artworkPath, fit: BoxFit.contain),
|
||
),
|
||
),
|
||
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.action == 'openReportUpload') {
|
||
pushRoute(ref, 'reports', params: {'openUpload': 'true'});
|
||
} 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_illustration_consultation.png',
|
||
ActiveAgent.health => 'assets/branding/agent_illustration_health.png',
|
||
ActiveAgent.diet => 'assets/branding/agent_illustration_diet.png',
|
||
ActiveAgent.medication =>
|
||
'assets/branding/agent_illustration_medication.png',
|
||
ActiveAgent.report => 'assets/branding/agent_illustration_report.png',
|
||
ActiveAgent.exercise => 'assets/branding/agent_illustration_exercise.png',
|
||
_ => 'assets/branding/agent_illustration_health.png',
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 2. DataConfirmCard — 统一确认卡片(紫白蓝风格)
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
Widget _buildDataConfirmCard(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
ChatMessage msg,
|
||
) {
|
||
final meta = msg.metadata ?? <String, dynamic>{};
|
||
final backendType = meta['type'] as String? ?? '';
|
||
final medicationOperation = meta['operation']?.toString() ?? 'create';
|
||
final isMedicationCheckIn = medicationOperation == 'confirm';
|
||
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;
|
||
final rawItems = meta['items'];
|
||
final healthItems = rawItems is List
|
||
? rawItems
|
||
.whereType<Map>()
|
||
.map((item) => Map<String, dynamic>.from(item))
|
||
.toList()
|
||
: <Map<String, dynamic>>[];
|
||
const confirmGradient = AppColors.primaryGradient;
|
||
const confirmOutline = AppColors.actionOutlineGradient;
|
||
|
||
String title = '数据确认';
|
||
IconData titleIcon = Icons.assignment_outlined;
|
||
String mainLabel = '';
|
||
String mainValue = '';
|
||
String mainUnit = '';
|
||
bool abnormal = meta['abnormal'] as bool? ?? false;
|
||
var headerColor = AppColors.primary;
|
||
String recordTime = meta['recordTime'] as String? ?? '';
|
||
var itemCount = 1;
|
||
var confirmationHint = '请核对后确认,本次将一并录入';
|
||
final fields = <_ConfirmField>[];
|
||
|
||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||
|
||
if (isMedication) {
|
||
title = isMedicationCheckIn ? '服药打卡确认' : '药品录入确认';
|
||
titleIcon = AppModuleVisuals.medication.icon;
|
||
final name = meta['name']?.toString() ?? '';
|
||
final dosage = meta['dosage']?.toString() ?? '';
|
||
final frequency = meta['frequency']?.toString() ?? '';
|
||
final time = meta['time']?.toString() ?? '';
|
||
final duration =
|
||
int.tryParse(meta['duration_days']?.toString() ?? '') ?? 0;
|
||
|
||
if (name.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: AppModuleVisuals.medication.icon,
|
||
label: '药品',
|
||
value: name,
|
||
),
|
||
);
|
||
}
|
||
if (dosage.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.science_outlined,
|
||
label: '剂量',
|
||
value: dosage,
|
||
),
|
||
);
|
||
}
|
||
if (frequency.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.event_repeat_outlined,
|
||
label: '频率',
|
||
value: _freqLabel(frequency),
|
||
),
|
||
);
|
||
}
|
||
if (time.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.schedule_outlined,
|
||
label: '时间',
|
||
value: time,
|
||
),
|
||
);
|
||
}
|
||
if (duration > 0) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.calendar_today_outlined,
|
||
label: '周期',
|
||
value: '$duration 天',
|
||
),
|
||
);
|
||
}
|
||
confirmationHint = isMedicationCheckIn
|
||
? '请核对后确认,本次将完成服药打卡'
|
||
: '请核对后确认,本次将创建用药计划';
|
||
} else if (isExercise) {
|
||
title = '运动计划确认';
|
||
titleIcon = AppModuleVisuals.exercise.icon;
|
||
final exerciseName =
|
||
(meta['value'] ?? meta['name'] ?? meta['exerciseType'] ?? '运动计划')
|
||
.toString();
|
||
final duration = (meta['unit'] ?? meta['durationUnit'] ?? '').toString();
|
||
final days =
|
||
int.tryParse(
|
||
(meta['durationDays'] ?? meta['day_count'])?.toString() ?? '',
|
||
) ??
|
||
0;
|
||
final reminder = (meta['reminderTime'] ?? meta['reminder_time'] ?? '')
|
||
.toString();
|
||
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.directions_walk_outlined,
|
||
label: '运动',
|
||
value: exerciseName,
|
||
),
|
||
);
|
||
if (duration.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.timer_outlined,
|
||
label: '时长',
|
||
value: duration,
|
||
),
|
||
);
|
||
}
|
||
if (days > 0) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.calendar_today_outlined,
|
||
label: '周期',
|
||
value: '连续 $days 天',
|
||
),
|
||
);
|
||
}
|
||
if (reminder.isNotEmpty) {
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: Icons.notifications_none_rounded,
|
||
label: '提醒',
|
||
value: reminder,
|
||
),
|
||
);
|
||
}
|
||
confirmationHint = '请核对后确认,本次将创建运动计划';
|
||
} else {
|
||
title = '健康数据确认';
|
||
titleIcon = AppModuleVisuals.healthOverviewIcon;
|
||
final items = healthItems.isNotEmpty
|
||
? healthItems
|
||
: [
|
||
<String, dynamic>{
|
||
'type': backendType,
|
||
'value': meta['value']?.toString() ?? '',
|
||
'unit': meta['unit']?.toString() ?? _getMetricUnit(backendType),
|
||
'abnormal': meta['abnormal'] == true,
|
||
},
|
||
];
|
||
itemCount = items.length;
|
||
abnormal = items.any((item) => item['abnormal'] == true);
|
||
for (final item in items) {
|
||
final type = item['type']?.toString() ?? '';
|
||
if (items.length == 1) headerColor = _getMetricColor(type);
|
||
fields.add(
|
||
_ConfirmField(
|
||
icon: _getMetricIconData(type),
|
||
label: _getMetricName(type),
|
||
value: item['value']?.toString() ?? '',
|
||
unit: item['unit']?.toString() ?? _getMetricUnit(type),
|
||
abnormal: item['abnormal'] == true,
|
||
color: _getMetricColor(type),
|
||
),
|
||
);
|
||
}
|
||
confirmationHint = itemCount > 1
|
||
? '请核对后确认,本次将一并录入 $itemCount 项健康数据'
|
||
: '请核对后确认,本次将录入 1 项健康数据';
|
||
}
|
||
|
||
final showLegacySummary = fields.isEmpty;
|
||
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 16),
|
||
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
|
||
decoration: BoxDecoration(
|
||
gradient: const LinearGradient(
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)],
|
||
),
|
||
borderRadius: BorderRadius.circular(28),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: AppColors.primary.withValues(alpha: 0.16),
|
||
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: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: headerColor.withValues(alpha: 0.16),
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
color: headerColor.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: headerColor.withValues(alpha: 0.12),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
],
|
||
),
|
||
child: Icon(titleIcon, size: 28, color: headerColor),
|
||
),
|
||
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.textHint,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
recordTime,
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 11,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: headerColor.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(
|
||
color: headerColor.withValues(alpha: 0.20),
|
||
),
|
||
),
|
||
child: Text(
|
||
'$itemCount 项',
|
||
style: TextStyle(
|
||
color: headerColor,
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ── 主要数据展示区(大卡片) ──
|
||
if (showLegacySummary)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(1.4),
|
||
decoration: BoxDecoration(
|
||
gradient: confirmOutline,
|
||
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(
|
||
color: AppColors.primarySoft,
|
||
borderRadius: BorderRadius.circular(18),
|
||
),
|
||
child: Center(
|
||
child: isHealth
|
||
? Icon(
|
||
_getMetricIconData(backendType),
|
||
size: 34,
|
||
color: AppColors.primary,
|
||
)
|
||
: Icon(
|
||
isMedication
|
||
? AppModuleVisuals.medication.icon
|
||
: AppModuleVisuals.exercise.icon,
|
||
size: 36,
|
||
color: AppColors.primary,
|
||
),
|
||
),
|
||
),
|
||
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.w600,
|
||
color: abnormal
|
||
? AppColors.error
|
||
: AppColors.textPrimary,
|
||
letterSpacing: -0.5,
|
||
),
|
||
),
|
||
if (mainUnit.isNotEmpty)
|
||
TextSpan(
|
||
text: ' $mainUnit',
|
||
style: TextStyle(
|
||
fontSize: 19,
|
||
color: abnormal
|
||
? AppColors.error
|
||
: AppColors.primary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// ── 异常提示 ──
|
||
if (abnormal) ...[
|
||
const SizedBox(height: 16),
|
||
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.primaryLight),
|
||
),
|
||
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: 14),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 14,
|
||
vertical: 11,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.primarySoft,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.verified_user_outlined,
|
||
size: 19,
|
||
color: AppColors.primary,
|
||
),
|
||
const SizedBox(width: 9),
|
||
Expanded(
|
||
child: Text(
|
||
confirmationHint,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
height: 1.35,
|
||
color: AppColors.textSecondary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: Container(
|
||
width: double.infinity,
|
||
height: 56,
|
||
padding: const EdgeInsets.all(1.4),
|
||
decoration: BoxDecoration(
|
||
gradient: confirmOutline,
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: AppColors.primary.withValues(alpha: 0.20),
|
||
blurRadius: 18,
|
||
offset: const Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
child: msg.confirmed || msg.isReadOnly
|
||
? Container(
|
||
decoration: BoxDecoration(
|
||
gradient: AppColors.successButtonGradient,
|
||
borderRadius: BorderRadius.circular(18),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.check_circle_outline,
|
||
size: 22,
|
||
color: Colors.white,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
msg.isReadOnly
|
||
? '历史记录'
|
||
: (isMedicationCheckIn ? '打卡成功' : '录入成功'),
|
||
style: const 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: confirmGradient,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Icon(
|
||
Icons.check_rounded,
|
||
size: 19,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
isMedicationCheckIn ? '确认打卡' : '确认录入',
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFieldRow(_ConfirmField field) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: (field.color ?? AppColors.primary).withValues(alpha: 0.10),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
field.icon ?? Icons.list_alt_rounded,
|
||
size: 22,
|
||
color: field.abnormal
|
||
? AppColors.error
|
||
: (field.color ?? AppColors.primary),
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
SizedBox(width: 72, child: _buildFieldLabel(field.label)),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.centerRight,
|
||
child: _buildFieldValue(field, TextAlign.right),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFieldLabel(String label) {
|
||
return Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
color: AppColors.textSecondary,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFieldValue(_ConfirmField field, TextAlign textAlign) {
|
||
return Text.rich(
|
||
TextSpan(
|
||
children: [
|
||
TextSpan(text: field.value.isNotEmpty ? field.value : '未设置'),
|
||
if (field.unit.isNotEmpty)
|
||
TextSpan(
|
||
text: ' ${field.unit}',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
textAlign: textAlign,
|
||
maxLines: 1,
|
||
softWrap: false,
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 3. ThinkingBubble — 思考动画
|
||
// ═══════════════════════════════════════════════════════════
|
||
Widget _buildThinkingBubble(BuildContext context, String? thinkingText) {
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||
decoration: const BoxDecoration(
|
||
color: Color(0xFFF8F8FF),
|
||
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,
|
||
) {
|
||
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.zero,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: isUser ? const Color(0xFF5F56FF) : const Color(0xFFFAFAFF),
|
||
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
|
||
? AuthenticatedNetworkImage(
|
||
imageUrl: 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) const AiGeneratedNote(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 工具方法
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
static void _showFullImage(BuildContext context, String? path) {
|
||
if (path == null) return;
|
||
final isNetwork =
|
||
path.startsWith('http://') ||
|
||
path.startsWith('https://') ||
|
||
path.startsWith('/uploads/') ||
|
||
path.startsWith('/api/');
|
||
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: isNetwork
|
||
? AuthenticatedNetworkImage(
|
||
imageUrl: 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 链接点击:
|
||
/// - 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) {
|
||
final normalized = freq.trim().toLowerCase().replaceAll(
|
||
RegExp(r'[\s_-]+'),
|
||
'',
|
||
);
|
||
switch (normalized) {
|
||
case 'daily':
|
||
case 'oncedaily':
|
||
return '每天一次';
|
||
case 'twicedaily':
|
||
return '每天两次';
|
||
case 'threetimesdaily':
|
||
return '每天三次';
|
||
case 'everyotherday':
|
||
return '隔天一次';
|
||
case 'weekly':
|
||
return '每周一次';
|
||
case 'monthly':
|
||
return '每月一次';
|
||
case 'asneeded':
|
||
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 (_normalizeMetricType(type)) {
|
||
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 (_normalizeMetricType(type)) {
|
||
case 'blood_pressure':
|
||
return HealthMetricVisuals.bloodPressureIcon;
|
||
case 'heart_rate':
|
||
return HealthMetricVisuals.heartRateIcon;
|
||
case 'glucose':
|
||
return HealthMetricVisuals.glucoseIcon;
|
||
case 'spo2':
|
||
return HealthMetricVisuals.spo2Icon;
|
||
case 'weight':
|
||
return HealthMetricVisuals.weightIcon;
|
||
case 'exercise':
|
||
return Icons.directions_run_outlined;
|
||
default:
|
||
return Icons.query_stats_outlined;
|
||
}
|
||
}
|
||
|
||
Color _getMetricColor(String type) {
|
||
switch (_normalizeMetricType(type)) {
|
||
case 'blood_pressure':
|
||
return HealthMetricVisuals.bloodPressureColor;
|
||
case 'heart_rate':
|
||
return HealthMetricVisuals.heartRateColor;
|
||
case 'glucose':
|
||
return HealthMetricVisuals.glucoseColor;
|
||
case 'spo2':
|
||
return HealthMetricVisuals.spo2Color;
|
||
case 'weight':
|
||
return HealthMetricVisuals.weightColor;
|
||
default:
|
||
return AppColors.primary;
|
||
}
|
||
}
|
||
|
||
String _getMetricName(String type) {
|
||
switch (_normalizeMetricType(type)) {
|
||
case 'blood_pressure':
|
||
return '血压';
|
||
case 'heart_rate':
|
||
return '心率';
|
||
case 'glucose':
|
||
return '血糖';
|
||
case 'spo2':
|
||
return '血氧';
|
||
case 'weight':
|
||
return '体重';
|
||
case 'exercise':
|
||
return '运动计划';
|
||
default:
|
||
return '健康指标';
|
||
}
|
||
}
|
||
|
||
String _normalizeMetricType(String type) {
|
||
return switch (type.trim().toLowerCase().replaceAll('-', '_')) {
|
||
'bloodpressure' || 'blood_pressure' => 'blood_pressure',
|
||
'heartrate' || 'heart_rate' || 'pulse' => 'heart_rate',
|
||
'glucose' || 'bloodglucose' || 'blood_glucose' => 'glucose',
|
||
'spo2' || 'bloodoxygen' || 'blood_oxygen' => 'spo2',
|
||
'weight' => 'weight',
|
||
'exercise' => 'exercise',
|
||
final normalized => normalized,
|
||
};
|
||
}
|
||
|
||
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,
|
||
iconColor: Colors.white,
|
||
verticalGradient: true,
|
||
);
|
||
|
||
return switch (agent) {
|
||
ActiveAgent.health => _AgentColors(
|
||
gradient: AppModuleVisuals.health.gradient.colors,
|
||
bg: AppModuleVisuals.health.lightColor,
|
||
border: AppModuleVisuals.health.borderColor,
|
||
iconBg: AppModuleVisuals.health.lightColor,
|
||
accent: AppModuleVisuals.health.color,
|
||
iconColor: Colors.white,
|
||
verticalGradient: true,
|
||
),
|
||
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),
|
||
iconColor: Colors.white,
|
||
verticalGradient: true,
|
||
),
|
||
_ => _AgentColors(
|
||
gradient: [AppColors.primary, AppColors.blueMeasure],
|
||
bg: const Color(0xFFF6F3FF),
|
||
border: const Color(0xFFE7E0FF),
|
||
iconBg: const Color(0xFFF0EDFF),
|
||
accent: AppColors.primary,
|
||
iconColor: Colors.white,
|
||
),
|
||
};
|
||
}
|
||
|
||
static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
|
||
return switch (agent) {
|
||
ActiveAgent.health => (
|
||
AppModuleVisuals.healthOverviewIcon,
|
||
'记数据',
|
||
'录入血压、血糖、心率等日常指标',
|
||
),
|
||
ActiveAgent.diet => (AppModuleVisuals.diet.icon, '拍饮食', '拍照识别食物热量和营养成分'),
|
||
ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'),
|
||
ActiveAgent.consultation => (
|
||
LucideIcons.messageCircle,
|
||
'AI问诊',
|
||
'现在由小脉为您进行预问诊,您可以放心说出身体的不适和疑问',
|
||
),
|
||
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'),
|
||
ActiveAgent.exercise => (
|
||
Icons.directions_run_outlined,
|
||
'运动',
|
||
'制定运动计划,打卡记录进度',
|
||
),
|
||
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
|
||
};
|
||
}
|
||
|
||
static String _agentTip(ActiveAgent agent) => switch (agent) {
|
||
ActiveAgent.health => '请提供指标名称、具体数值和测量时间,如"今天早上血压130/85、心率72"',
|
||
ActiveAgent.diet => '请提供餐次、食物和分量,或直接拍照,如"早餐2个鸡蛋、1杯牛奶"',
|
||
ActiveAgent.medication => '请提供药品名称、每次剂量、服药频率和时间,如"阿司匹林每次1片、每天早晚8点"',
|
||
ActiveAgent.consultation => '请说明症状、出现时间、持续多久和严重程度,小脉会继续追问并给出建议',
|
||
ActiveAgent.report => '请上传完整报告或清晰照片,并说明检查项目和日期,AI 将提取指标并解读',
|
||
ActiveAgent.exercise => '请提供运动类型、每次时长、每周频率和目标,如"散步30分钟、每周5天"',
|
||
_ => '直接描述您的需求,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 snapshotAsync = ref.watch(todayHealthSnapshotProvider);
|
||
final snapshot = snapshotAsync.value;
|
||
if (snapshot == null) {
|
||
return _taskCardPlaceholder(
|
||
snapshotAsync.hasError ? '今日健康暂时无法获取' : '正在获取今日健康',
|
||
isError: snapshotAsync.hasError,
|
||
onRetry: () => ref.invalidate(todayHealthSnapshotProvider),
|
||
);
|
||
}
|
||
return _taskCardBubble(
|
||
context,
|
||
ref,
|
||
snapshot.health,
|
||
AsyncValue.data(snapshot.reminders),
|
||
AsyncValue.data(snapshot.exercisePlan),
|
||
);
|
||
}
|
||
|
||
Widget _taskCardPlaceholder(
|
||
String message, {
|
||
required bool isError,
|
||
required VoidCallback onRetry,
|
||
}) {
|
||
final now = DateTime.now();
|
||
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, 13, 16, 11),
|
||
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: [
|
||
const Text(
|
||
'今日健康',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'${now.month}月${now.day}日',
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
InkWell(
|
||
onTap: isError ? onRetry : null,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 11),
|
||
child: Row(
|
||
children: [
|
||
SizedBox.square(
|
||
dimension: 28,
|
||
child: isError
|
||
? const Icon(
|
||
LucideIcons.refreshCw,
|
||
size: 18,
|
||
color: AppColors.textSecondary,
|
||
)
|
||
: const CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
isError ? '$message,点击重试' : message,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _taskCardBubble(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
Map<String, dynamic> healthData,
|
||
AsyncValue<List<Map<String, dynamic>>> reminders,
|
||
AsyncValue<Map<String, dynamic>?> exercisePlan,
|
||
) {
|
||
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;
|
||
|
||
// ── 多指标异常检测 ──
|
||
// 优先使用后端保存时计算出的 IsAbnormal,兜底规则必须和后端 HealthRecordRules 保持一致。
|
||
final abnormals = <String>[];
|
||
final bpAbnormal = _recordIsAbnormal(
|
||
bp,
|
||
fallback: () {
|
||
if (bp is! Map) return false;
|
||
final s = _numValue(bp['systolic'] ?? bp['Systolic']);
|
||
final d = _numValue(bp['diastolic'] ?? bp['Diastolic']);
|
||
return s != null &&
|
||
d != null &&
|
||
(s >= 140 || d >= 90 || s <= 89 || d <= 59);
|
||
},
|
||
);
|
||
if (bpAbnormal && bp is Map) {
|
||
final s = _numValue(bp['systolic'] ?? bp['Systolic']);
|
||
final d = _numValue(bp['diastolic'] ?? bp['Diastolic']);
|
||
abnormals.add(
|
||
'血压 ${s?.toStringAsFixed(0) ?? '--'}/${d?.toStringAsFixed(0) ?? '--'} 异常',
|
||
);
|
||
}
|
||
final hrAbnormal = _recordIsAbnormal(
|
||
hr,
|
||
fallback: () {
|
||
final v = _numValue(hr is Map ? hr['value'] ?? hr['Value'] : null);
|
||
return v != null && (v > 100 || v < 60);
|
||
},
|
||
);
|
||
if (hrAbnormal && hr is Map) {
|
||
final v = _numValue(hr['value'] ?? hr['Value']);
|
||
abnormals.add('心率 ${v?.toStringAsFixed(0) ?? '--'} 异常');
|
||
}
|
||
final bsAbnormal = _recordIsAbnormal(
|
||
bs,
|
||
fallback: () {
|
||
final v = _numValue(bs is Map ? bs['value'] ?? bs['Value'] : null);
|
||
return v != null && (v > 6.1 || v < 3.9);
|
||
},
|
||
);
|
||
if (bsAbnormal && bs is Map) {
|
||
final v = _numValue(bs['value'] ?? bs['Value']);
|
||
abnormals.add('血糖 ${v?.toStringAsFixed(1) ?? '--'} 异常');
|
||
}
|
||
final boAbnormal = _recordIsAbnormal(
|
||
bo,
|
||
fallback: () {
|
||
final v = _numValue(bo is Map ? bo['value'] ?? bo['Value'] : null);
|
||
return v != null && v <= 94;
|
||
},
|
||
);
|
||
if (boAbnormal && bo is Map) {
|
||
final v = _numValue(bo['value'] ?? bo['Value']);
|
||
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,
|
||
AppModuleVisuals.healthOverviewIcon,
|
||
'健康指标',
|
||
trailing: trailing,
|
||
status: 'warning',
|
||
iconGradient: AppModuleVisuals.health.gradient,
|
||
onTap: () => pushRoute(ref, 'trend'),
|
||
),
|
||
);
|
||
} else {
|
||
// 全部正常
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
AppModuleVisuals.healthOverviewIcon,
|
||
'健康指标',
|
||
trailing: '指标正常',
|
||
status: 'done',
|
||
iconColor: healthIconColor,
|
||
iconBg: healthIconBg,
|
||
iconGradient: AppModuleVisuals.health.gradient,
|
||
onTap: () => pushRoute(ref, 'trend'),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 2. 运动 ──
|
||
final exIconColor = AppModuleVisuals.exercise.color;
|
||
final exIconBg = AppModuleVisuals.exercise.lightColor;
|
||
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,
|
||
AppModuleVisuals.exercise.icon,
|
||
'$name $dur分钟',
|
||
status: done
|
||
? 'done'
|
||
: (now.hour >= 18 ? 'overdue' : 'pending'),
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
loading: () {
|
||
hasExercise = true;
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
AppModuleVisuals.exercise.icon,
|
||
'运动',
|
||
trailing: '正在加载运动计划',
|
||
status: 'pending',
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
},
|
||
error: (_, _) {
|
||
hasExercise = true;
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
AppModuleVisuals.exercise.icon,
|
||
'运动',
|
||
trailing: '运动计划加载失败',
|
||
status: 'overdue',
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
if (!hasExercise) {
|
||
tasks.add(
|
||
_taskRow(
|
||
context,
|
||
AppModuleVisuals.exercise.icon,
|
||
'运动',
|
||
trailing: '暂无今日运动计划',
|
||
status: 'pending',
|
||
iconColor: exIconColor,
|
||
iconBg: exIconBg,
|
||
iconGradient: AppModuleVisuals.exercise.gradient,
|
||
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,
|
||
AppModuleVisuals.medication.icon,
|
||
'用药',
|
||
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||
status: 'done',
|
||
iconColor: medIconColor,
|
||
iconBg: medIconBg,
|
||
iconGradient: AppModuleVisuals.medication.gradient,
|
||
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,
|
||
AppModuleVisuals.medication.icon,
|
||
title,
|
||
trailing: trailing,
|
||
status: status,
|
||
iconColor: medIconColor,
|
||
iconBg: medIconBg,
|
||
iconGradient: AppModuleVisuals.medication.gradient,
|
||
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, 13, 16, 11),
|
||
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: [
|
||
const Text(
|
||
'今日健康',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
height: 1.15,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'${now.month}月${now.day}日',
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 5, 14, 7),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (tasks.isEmpty)
|
||
_taskRow(
|
||
context,
|
||
LucideIcons.circleCheck,
|
||
'今日暂无待办',
|
||
status: 'done',
|
||
)
|
||
else
|
||
...tasks,
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _taskRow(
|
||
BuildContext context,
|
||
IconData icon,
|
||
String label, {
|
||
String status = 'pending',
|
||
String? trailing,
|
||
VoidCallback? onTap,
|
||
Color? iconColor,
|
||
Color? iconBg,
|
||
LinearGradient? iconGradient,
|
||
}) {
|
||
final colors = {
|
||
'done': AppTheme.success,
|
||
'warning': const Color(0xFFE8562A),
|
||
'pending': AppColors.textHint,
|
||
'overdue': AppTheme.error,
|
||
};
|
||
final icons = {
|
||
'done': LucideIcons.circleCheck,
|
||
'warning': LucideIcons.triangleAlert,
|
||
'pending': LucideIcons.circle,
|
||
'overdue': LucideIcons.circleAlert,
|
||
};
|
||
final ic = iconColor ?? AppTheme.primary;
|
||
final ibg = iconBg ?? AppTheme.primaryLight;
|
||
final textColor = switch (status) {
|
||
'overdue' => AppColors.error,
|
||
'warning' => const Color(0xFFE8562A),
|
||
_ => AppColors.textPrimary,
|
||
};
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: AppRadius.smBorder,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: iconGradient == null ? ibg : null,
|
||
gradient: iconGradient,
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Icon(
|
||
icon,
|
||
size: 18,
|
||
color: iconGradient == null ? ic : Colors.white,
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
trailing ?? label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: status == 'overdue' || status == 'warning'
|
||
? FontWeight.w600
|
||
: FontWeight.w500,
|
||
color: textColor,
|
||
),
|
||
),
|
||
),
|
||
Icon(
|
||
icons[status] ?? Icons.circle_outlined,
|
||
size: 20,
|
||
color: colors[status] ?? Colors.grey,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _recordIsAbnormal(dynamic record, {required bool Function() fallback}) {
|
||
if (record is Map) {
|
||
final raw =
|
||
record['isAbnormal'] ?? record['IsAbnormal'] ?? record['abnormal'];
|
||
if (raw is bool) return raw;
|
||
if (raw is String) {
|
||
final normalized = raw.toLowerCase();
|
||
if (normalized == 'true') return true;
|
||
if (normalized == 'false') return false;
|
||
}
|
||
if (raw is num) return raw != 0;
|
||
}
|
||
return fallback();
|
||
}
|
||
|
||
double? _numValue(dynamic value) {
|
||
if (value is num) return value.toDouble();
|
||
if (value is String) return double.tryParse(value);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
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(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.iconColor)),
|
||
);
|
||
}
|
||
}
|
||
|
||
typedef _AgentIcon = IconData;
|
||
|
||
class _AgentColors {
|
||
final List<Color> gradient;
|
||
final Color bg;
|
||
final Color border;
|
||
final Color iconBg;
|
||
final Color accent;
|
||
final Color iconColor;
|
||
final bool verticalGradient;
|
||
const _AgentColors({
|
||
required this.gradient,
|
||
required this.bg,
|
||
required this.border,
|
||
required this.iconBg,
|
||
required this.accent,
|
||
required this.iconColor,
|
||
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 IconData? icon;
|
||
final String label;
|
||
final String value;
|
||
final String unit;
|
||
final bool abnormal;
|
||
final Color? color;
|
||
|
||
const _ConfirmField({
|
||
this.icon,
|
||
required this.label,
|
||
required this.value,
|
||
this.unit = '',
|
||
this.abnormal = false,
|
||
this.color,
|
||
});
|
||
}
|
||
|
||
final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||
ActiveAgent.health: [
|
||
_AgentAction(
|
||
label: '健康概览',
|
||
icon: AppModuleVisuals.healthOverviewIcon,
|
||
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: AppModuleVisuals.medication.icon,
|
||
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,
|
||
action: 'openReportUpload',
|
||
),
|
||
_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)];
|
||
}
|