Files
AI-Health/health_app/lib/pages/home/widgets/chat_messages_view.dart
MingNian b4bc15b9b9 feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
2026-07-28 15:03:38 +08:00

2268 lines
78 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_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)];
}