import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../core/app_colors.dart'; import '../../../core/app_theme.dart'; import '../../../core/api_client.dart' show baseUrl; import '../../../core/navigation_provider.dart'; import '../../../providers/chat_provider.dart'; import '../../../providers/data_providers.dart'; /// 卡片入场动画包装(从下往上滑入 + 淡入) class _AnimatedCardEntry extends StatefulWidget { final Widget child; const _AnimatedCardEntry({required this.child}); @override State<_AnimatedCardEntry> createState() => _AnimatedCardEntryState(); } class _AnimatedCardEntryState extends State<_AnimatedCardEntry> with SingleTickerProviderStateMixin { late final _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); late final _slide = Tween( begin: const Offset(0, 0.12), end: Offset.zero, ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); late final _fade = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); @override void initState() { super.initState(); _ctrl.forward(); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FadeTransition( opacity: _fade, child: SlideTransition(position: _slide, child: widget.child), ); } } /// 对话消息列表 class ChatMessagesView extends ConsumerWidget { final ScrollController scrollCtrl; final List messages; const ChatMessagesView({ super.key, required this.scrollCtrl, required this.messages, }); @override Widget build(BuildContext context, WidgetRef ref) { final chatState = ref.watch(chatProvider); if (messages.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(40), ), child: const Icon( Icons.health_and_safety, size: 40, color: AppTheme.primary, ), ), const SizedBox(height: 16), Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 8), const Text( '记录健康数据,获取专业建议', style: TextStyle(fontSize: 17, color: AppColors.textHint), ), ], ), ); } return ListView.builder( controller: scrollCtrl, reverse: false, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[index]; return _buildMessageContent(context, ref, msg, chatState); }, ); } // ─── 消息分发 ───────────────────────────────────────────── Widget _buildMessageContent( BuildContext context, WidgetRef ref, ChatMessage msg, ChatState chatState, ) { switch (msg.type) { case MessageType.agentWelcome: final storedAgent = _parseAgentFromName( msg.metadata?['agent'] as String?, ); return _buildAgentWelcomeCard(context, ref, msg, storedAgent); case MessageType.taskCard: return _buildTaskCardInChat(context, ref); case MessageType.dataConfirm: return _buildDataConfirmCard(context, ref, msg); default: if (!msg.isUser && chatState.isStreaming && msg.content.trim().isEmpty) { return _buildThinkingBubble(context, chatState.thinkingText); } return _buildTextBubble(context, ref, msg, chatState); } } // ═══════════════════════════════════════════════════════════ // 1. AgentWelcomeCard — 智能体欢迎卡片(蚂蚁阿福风格 + 美化版) // ═══════════════════════════════════════════════════════════ Widget _buildAgentWelcomeCard( BuildContext context, WidgetRef ref, ChatMessage msg, ActiveAgent agent, ) { final info = _agentInfo(agent); final actions = agent.actions; final screenWidth = MediaQuery.sizeOf(context).width; final agentColors = _agentColors(agent); final artworkPath = _agentArtworkPath(agent); return _AnimatedCardEntry( child: Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: const Color(0xFF6B5CE7).withValues(alpha: 0.12), blurRadius: 28, offset: const Offset(0, 14), ), BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4), ), ], ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ // ── 标题区域 ── Stack( children: [ Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( colors: [ Colors.white, agentColors.bg.withValues(alpha: 0.86), agentColors.accent.withValues(alpha: 0.12), ], stops: const [0.0, 0.58, 1.0], begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), ), ), Positioned( right: 18, top: 18, child: _MiniSignalStrip(colors: agentColors), ), Positioned( right: 18, bottom: 18, child: _LinearAccentMark(colors: agentColors), ), Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(18, 20, 18, 22), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _AgentMark(icon: info.$1, colors: agentColors), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.76), borderRadius: BorderRadius.circular(999), border: Border.all( color: agentColors.accent.withValues( alpha: 0.18, ), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.graphic_eq, size: 14, color: agentColors.accent, ), const SizedBox(width: 4), Text( 'AI 智能助手', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: agentColors.accent, ), ), ], ), ), const SizedBox(height: 9), Text( info.$2, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), const SizedBox(height: 8), Text( info.$3, style: const TextStyle( fontSize: 16, color: AppColors.textSecondary, height: 1.4, ), ), ], ), ), ], ), ), ], ), // ── 插画展示 ── Image.asset( artworkPath, width: double.infinity, height: 206, fit: BoxFit.cover, ), // ── AI问诊引导 ── if (agent == ActiveAgent.consultation) ...[ const SizedBox(height: 14), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFFF0F4FF), borderRadius: BorderRadius.circular(14), ), child: const Text( '在下方输入框描述您的症状,AI医生将为您提供专业建议', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), ), ), ], if (actions.isNotEmpty) ...[ const SizedBox(height: 14), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (int i = 0; i < actions.length; i++) ...[ if (i > 0) const SizedBox(width: 10), Flexible( child: _buildActionButton( actions[i], context, ref, agentColors, ), ), ], ], ), ), ], // ── 底部提示 ── const SizedBox(height: 16), Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), child: Container( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10, ), decoration: BoxDecoration( color: agentColors.bg, borderRadius: BorderRadius.circular(14), border: Border.all( color: agentColors.accent.withValues(alpha: 0.14), ), ), child: Row( children: [ Icon( Icons.lightbulb_outline, size: 18, color: agentColors.accent, ), const SizedBox(width: 8), Expanded( child: Text( _agentTip(agent), style: TextStyle( fontSize: 15, color: AppColors.textPrimary.withAlpha(180), fontWeight: FontWeight.w500, ), ), ), ], ), ), ), ], ), ), ), ); } Widget _buildActionButton( _AgentAction a, BuildContext context, WidgetRef ref, _AgentColors colors, ) { return InkWell( onTap: () { if (a.label == '服药打卡') { pushRoute(ref, 'medCheckIn'); } else if (a.action == 'pickFoodCamera' || a.action == 'pickFoodGallery') { ref.read(dietActionProvider.notifier).trigger(a.action!); } else if (a.route != null) { if (a.route == 'camera' || a.route == 'gallery') { ref.read(cameraActionProvider.notifier).trigger(a.route!); } else { pushRoute(ref, a.route!); } } }, borderRadius: BorderRadius.circular(28), child: Container( padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( gradient: AppColors.actionOutlineGradient, borderRadius: BorderRadius.circular(28), ), child: Container( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(26.5), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ ShaderMask( shaderCallback: (bounds) => AppColors.actionOutlineGradient.createShader(bounds), child: Icon(a.icon, size: 24, color: Colors.white), ), const SizedBox(width: 10), Text( a.label, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ], ), ), ), ); } String _agentArtworkPath(ActiveAgent agent) { return switch (agent) { ActiveAgent.consultation => 'assets/branding/agent_consultation_card.png', ActiveAgent.health => 'assets/branding/agent_health_card.png', ActiveAgent.diet => 'assets/branding/agent_diet_card.png', ActiveAgent.medication => 'assets/branding/agent_medication_card.png', ActiveAgent.report => 'assets/branding/agent_report_card.png', ActiveAgent.exercise => 'assets/branding/agent_exercise_card.png', _ => 'assets/branding/agent_health_card.png', }; } // ═══════════════════════════════════════════════════════════ // 2. DataConfirmCard — 统一确认卡片(紫白蓝风格) // ═══════════════════════════════════════════════════════════ Widget _buildDataConfirmCard( BuildContext context, WidgetRef ref, ChatMessage msg, ) { final meta = msg.metadata ?? {}; final backendType = meta['type'] as String? ?? ''; final screenWidth = MediaQuery.sizeOf(context).width; // 识别是哪种数据类型 final isMedication = backendType == 'medication' || (meta['name'] != null && meta['name'].toString().isNotEmpty && backendType != 'exercise'); final isExercise = backendType == 'exercise'; final isHealth = !isMedication && !isExercise; // 根据类型获取显示字段 String title = '数据确认'; IconData titleIcon = Icons.assignment; String mainLabel = ''; String mainValue = ''; String mainUnit = ''; bool abnormal = meta['abnormal'] as bool? ?? false; String recordTime = meta['recordTime'] as String? ?? ''; List<_ConfirmField> fields = []; if (isMedication) { // 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息 title = '药品录入确认'; titleIcon = Icons.medication_liquid_outlined; mainLabel = meta['name'] as String? ?? ''; mainValue = meta['dosage'] as String? ?? ''; mainUnit = ''; if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); final time = meta['time'] as String? ?? ''; if (time.isNotEmpty) { final timeValue = meta['服药时间'] as String? ?? time; fields.add(_ConfirmField(label: '服药时间', value: timeValue)); } final frequency = meta['frequency'] as String? ?? ''; if (frequency.isNotEmpty) { final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); fields.add(_ConfirmField(label: '频率', value: freqValue)); } final duration = meta['duration_days'] as int?; if (duration != null && duration > 0) { final durationValue = meta['服用天数'] as String? ?? '$duration 天'; fields.add(_ConfirmField(label: '服用天数', value: durationValue)); } } else if (isExercise) { // 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数 title = '运动计划确认'; titleIcon = Icons.directions_run_outlined; final exName = (meta['value'] ?? meta['name'] ?? meta['exerciseType'] ?? meta['运动类型'] ?? '') .toString(); final exUnitRaw = (meta['unit'] ?? meta['duration_unit'] ?? meta['durationUnit'] ?? '') .toString(); final exDaysRaw = meta['durationDays'] ?? meta['day_count']; final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0; // 解析 "每天30分钟" → 频率=每天, 时长=30分钟 final freqMatch = RegExp(r'^(每天|每周|每月)').firstMatch(exUnitRaw); final durMatch = RegExp(r'(\d+分钟)').firstMatch(exUnitRaw); final freq = freqMatch?.group(1) ?? ''; final dur = durMatch?.group(1) ?? exUnitRaw; mainLabel = '运动计划'; mainValue = exName.isNotEmpty ? exName : '运动计划'; mainUnit = ''; if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); if (dur.isNotEmpty) { fields.add(_ConfirmField(label: '运动时长', value: dur)); } if (freq.isNotEmpty) { fields.add(_ConfirmField(label: '频率', value: freq)); } if (exDays > 0) { fields.add(_ConfirmField(label: '计划天数', value: '$exDays天')); } final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString(); if (intensity.isNotEmpty) { fields.add(_ConfirmField(label: '运动强度', value: intensity)); } final calories = (meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '') .toString(); if (calories.isNotEmpty) { fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal')); } } else { // 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息 title = '健康数据确认'; titleIcon = Icons.monitor_heart_outlined; mainLabel = _getMetricName(backendType); mainValue = meta['value'] as String? ?? ''; mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType); if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); final timeValue = meta['记录时间'] as String? ?? recordTime; fields.add(_ConfirmField(label: '记录时间', value: timeValue)); final note = meta['note'] as String? ?? ''; if (note.isNotEmpty) { final noteValue = meta['备注'] as String? ?? note; fields.add(_ConfirmField(label: '备注', value: noteValue)); } } return _AnimatedCardEntry( child: Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( color: const Color(0xFF6366F1).withValues(alpha: 0.10), blurRadius: 28, offset: const Offset(0, 14), ), BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4), ), ], ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ // ── 标题区域 ── Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)], ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 56, height: 56, decoration: BoxDecoration( gradient: AppColors.actionOutlineGradient, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: AppColors.auraIndigo.withValues(alpha: 0.18), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: Icon(titleIcon, size: 28, color: Colors.white), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), const SizedBox(height: 6), Row( children: [ Icon( Icons.access_time, size: 15, color: AppColors.iconColor, ), const SizedBox(width: 4), Text( recordTime, style: const TextStyle( fontSize: 15, color: AppColors.textSecondary, ), ), ], ), ], ), ), ], ), ), // ── 主要数据展示区(大卡片) ── Padding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), child: Container( padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( gradient: AppColors.actionOutlineGradient, borderRadius: BorderRadius.circular(20), ), child: Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 68, height: 68, decoration: BoxDecoration( gradient: AppColors.lightGradient, borderRadius: BorderRadius.circular(18), ), child: Center( child: isHealth ? Icon( _getMetricIconData(backendType), size: 34, color: AppColors.primaryDark, ) : Icon( isMedication ? Icons.medication_liquid_outlined : Icons.directions_run_outlined, size: 36, color: AppColors.iconColor, ), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( mainLabel, style: const TextStyle( fontSize: 17, color: AppColors.textSecondary, ), ), if (mainValue.isNotEmpty) ...[ const SizedBox(height: 8), RichText( text: TextSpan( children: [ TextSpan( text: mainValue, style: TextStyle( fontSize: 36, fontWeight: FontWeight.w800, color: abnormal ? AppColors.error : AppColors.textPrimary, letterSpacing: -0.5, ), ), if (mainUnit.isNotEmpty) TextSpan( text: ' $mainUnit', style: TextStyle( fontSize: 19, color: abnormal ? AppColors.error : AppColors.iconColor, fontWeight: FontWeight.w500, ), ), ], ), ), ], ], ), ), ], ), ), ), ), // ── 异常提示 ── if (abnormal) ...[ const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), decoration: BoxDecoration( color: AppColors.error.withAlpha(10), borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.error.withAlpha(30)), ), child: Row( children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: AppColors.error.withAlpha(15), borderRadius: BorderRadius.circular(10), ), child: Icon( Icons.warning_amber_rounded, size: 20, color: AppColors.error, ), ), const SizedBox(width: 10), Expanded( child: Text( '数值偏高,建议关注并咨询医生', style: TextStyle( fontSize: 16, color: AppColors.error, fontWeight: FontWeight.w500, ), ), ), ], ), ), ), ], // ── 详细字段列表(可点击修改) ── if (fields.isNotEmpty) ...[ const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.border), ), child: Column( children: [ for (int i = 0; i < fields.length; i++) ...[ if (i > 0) Container( height: 1, color: AppColors.borderLight, margin: const EdgeInsets.symmetric( horizontal: 16, ), ), _buildFieldRow(fields[i]), ], ], ), ), ), ], // ── 确认按钮(白色底+渐变边框) ── const SizedBox(height: 18), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( width: double.infinity, height: 56, padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( gradient: AppColors.actionOutlineGradient, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: AppColors.auraIndigo.withValues(alpha: 0.18), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: msg.confirmed ? Container( decoration: BoxDecoration( gradient: AppColors.successButtonGradient, borderRadius: BorderRadius.circular(18), ), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.check_circle_outline, size: 22, color: Colors.white, ), SizedBox(width: 8), Text( '录入成功', style: TextStyle( fontSize: 19, fontWeight: FontWeight.w700, color: Colors.white, ), ), ], ), ) : Material( color: Colors.transparent, child: InkWell( onTap: () async { final error = await ref .read(chatProvider.notifier) .confirmMessage(msg.id); if (error != null && context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(error))); } }, borderRadius: BorderRadius.circular(18), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 32, height: 32, decoration: BoxDecoration( gradient: AppColors.actionOutlineGradient, borderRadius: BorderRadius.circular(10), ), child: const Icon( Icons.check_rounded, size: 19, color: Colors.white, ), ), const SizedBox(width: 10), const Text( '确认录入', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), ], ), ), ), ), ), ), const SizedBox(height: 20), ], ), ), ), ); } Widget _buildFieldRow(_ConfirmField field) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 80, child: Text( field.label, style: const TextStyle(fontSize: 15, color: AppColors.textHint), ), ), const SizedBox(width: 12), Expanded( child: Text( field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), ), ], ), ); } // ═══════════════════════════════════════════════════════════ // 3. ThinkingBubble — 思考动画 // ═══════════════════════════════════════════════════════════ Widget _buildThinkingBubble(BuildContext context, String? thinkingText) { return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( gradient: AppColors.actionOutlineGradient, borderRadius: const BorderRadius.only( topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20), ), boxShadow: [ BoxShadow( color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: Container( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), decoration: const BoxDecoration( color: AppColors.cardBackground, borderRadius: BorderRadius.only( topLeft: Radius.circular(3), topRight: Radius.circular(18.6), bottomLeft: Radius.circular(18.6), bottomRight: Radius.circular(18.6), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 26, height: 26, padding: const EdgeInsets.all(5), decoration: BoxDecoration( color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(13), ), child: const CircularProgressIndicator( strokeWidth: 2.2, color: AppTheme.primary, ), ), const SizedBox(width: 10), Text( thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', style: const TextStyle(fontSize: 17, color: AppColors.textHint), ), ], ), ), ), ); } Widget _buildTextBubble( BuildContext context, WidgetRef ref, ChatMessage msg, ChatState? chatState, ) { final isUser = msg.isUser; final imageUrl = msg.metadata?['imageUrl'] as String?; final localPath = msg.metadata?['localImagePath'] as String?; final pdfFileName = msg.metadata?['pdfFileName']?.toString() ?? msg.metadata?['fileName']?.toString(); final hasImage = imageUrl != null || localPath != null; final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), constraints: BoxConstraints( maxWidth: MediaQuery.sizeOf(context).width * 0.82, ), padding: EdgeInsets.all(isUser ? 0 : 1.4), decoration: isUser ? null : BoxDecoration( gradient: AppColors.actionOutlineGradient, borderRadius: const BorderRadius.only( topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20), ), boxShadow: [ BoxShadow( color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( color: isUser ? AppTheme.primary : AppColors.cardBackground, borderRadius: BorderRadius.only( topLeft: Radius.circular(isUser ? 18.6 : 3), topRight: Radius.circular(isUser ? 3 : 18.6), bottomLeft: const Radius.circular(18.6), bottomRight: const Radius.circular(18.6), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 文字内容 if (isUser) SelectableText( msg.content, style: const TextStyle( fontSize: 19, color: Colors.white, height: 1.5, ), ) else MarkdownBody( data: _cleanAiText(msg.content), selectable: true, onTapLink: (text, href, title) => _handleMarkdownLink(context, ref, href), styleSheet: MarkdownStyleSheet( p: const TextStyle( fontSize: 19, color: AppColors.textPrimary, height: 1.5, ), a: const TextStyle( fontSize: 19, color: AppColors.primary, fontWeight: FontWeight.w700, decoration: TextDecoration.underline, decorationColor: AppColors.primary, ), strong: const TextStyle( fontSize: 19, color: AppColors.textPrimary, height: 1.5, fontWeight: FontWeight.w700, ), h1: const TextStyle( fontSize: 22, color: AppColors.textPrimary, height: 1.35, fontWeight: FontWeight.w800, ), h2: const TextStyle( fontSize: 21, color: AppColors.textPrimary, height: 1.4, fontWeight: FontWeight.w800, ), h3: const TextStyle( fontSize: 20, color: AppColors.textPrimary, height: 1.4, fontWeight: FontWeight.w800, ), listBullet: const TextStyle( fontSize: 24, color: AppColors.textPrimary, height: 1.2, fontWeight: FontWeight.w900, ), listBulletPadding: const EdgeInsets.only(right: 8), ), ), // 图片缩略图(在文字下方) if (hasImage) Padding( padding: const EdgeInsets.only(top: 8), child: GestureDetector( onTap: () => _showFullImage(context, localPath ?? imageUrl), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints( maxWidth: 160, maxHeight: 120, ), child: localPath != null ? Image.file(File(localPath), fit: BoxFit.cover) : imageUrl != null ? Image.network( _mediaUrl(imageUrl), fit: BoxFit.cover, errorBuilder: (_, e, s) => Container( width: 80, height: 60, decoration: BoxDecoration( color: AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.image, size: 28, color: AppColors.textHint, ), ), ) : const SizedBox.shrink(), ), ), ), ), if (hasPdf) Container( margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), decoration: BoxDecoration( color: isUser ? Colors.white.withValues(alpha: 0.16) : AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.picture_as_pdf_outlined, size: 18, color: isUser ? Colors.white : AppColors.error, ), const SizedBox(width: 6), Flexible( child: Text( pdfFileName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: isUser ? Colors.white : AppColors.textPrimary, ), ), ), ], ), ), if (!isUser && msg.content.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 10), child: Row( children: [ const CircleAvatar( radius: 10, backgroundColor: AppTheme.primaryLight, child: Icon( Icons.chat_bubble_outline, size: 17, color: AppTheme.primary, ), ), const SizedBox(width: 6), const Text( '内容由 AI 生成', style: TextStyle( fontSize: 14, color: AppColors.textHint, ), ), ], ), ), ], ), ), ), ); } // ═══════════════════════════════════════════════════════════ // 工具方法 // ═══════════════════════════════════════════════════════════ static void _showFullImage(BuildContext context, String? path) { if (path == null) return; final resolvedPath = _mediaUrl(path); showDialog( context: context, builder: (ctx) => Dialog( backgroundColor: Colors.black, insetPadding: const EdgeInsets.all(24), child: Stack( alignment: Alignment.center, children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: InteractiveViewer( child: path.startsWith('http') ? Image.network(resolvedPath, fit: BoxFit.contain) : resolvedPath.startsWith('http') ? Image.network(resolvedPath, fit: BoxFit.contain) : Image.file(File(resolvedPath), fit: BoxFit.contain), ), ), Positioned( top: 8, right: 8, child: GestureDetector( onTap: () => Navigator.pop(ctx), child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.white54, shape: BoxShape.circle, ), child: const Icon(Icons.close, size: 21), ), ), ), ], ), ), ); } static String _mediaUrl(String path) { if (path.startsWith('http://') || path.startsWith('https://')) return path; if (path.startsWith('/uploads/')) return '$baseUrl$path'; return path; } /// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染) static String _cleanAiText(String text) { var t = text; // 移除 $1、$2 等占位符 t = t.replaceAll(RegExp(r'\$\d+'), ''); // 移除残留 HTML 标签 t = t.replaceAll(RegExp(r'<[^>]+>'), ''); // 行首 # 超过 3 个降为 ### t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### '); // 压缩多余空行 t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n'); return t.trim(); } /// 处理 AI 回复里的 markdown 链接点击: /// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程 /// - app://report → 跳到报告列表(用户可在那里上传新报告) /// - app://device → 跳到蓝牙设备页 /// - 其他 app://xxx → 当作 route name 直接跳 static void _handleMarkdownLink( BuildContext context, WidgetRef ref, String? href, ) { if (href == null || href.isEmpty) return; final uri = Uri.tryParse(href); if (uri == null) return; if (uri.scheme == 'app') { switch (uri.host) { case 'diet': ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食'); break; case 'report': pushRoute(ref, 'reports'); break; case 'device': pushRoute(ref, 'devices'); break; default: if (uri.host.isNotEmpty) pushRoute(ref, uri.host); } } } String _freqLabel(String freq) { switch (freq.toLowerCase()) { case 'daily': return '每天'; case 'everyotherday': return '隔天'; case 'weekly': return '每周'; default: return freq; } } String _formatTime(DateTime dt) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final thatDay = DateTime(dt.year, dt.month, dt.day); if (thatDay == today) { return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } if (thatDay == today.subtract(const Duration(days: 1))) { return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } String _getMetricUnit(String type) { switch (type.toLowerCase()) { case 'blood_pressure': return 'mmHg'; case 'heart_rate': return 'bpm'; case 'glucose': return 'mmol/L'; case 'spo2': return '%'; case 'weight': return 'kg'; case 'exercise': return '分钟/天'; default: return ''; } } IconData _getMetricIconData(String type) { switch (type.toLowerCase()) { case 'blood_pressure': return Icons.bloodtype_outlined; case 'heart_rate': return Icons.monitor_heart_outlined; case 'glucose': return Icons.water_drop_outlined; case 'spo2': return Icons.air_outlined; case 'weight': return Icons.monitor_weight_outlined; case 'exercise': return Icons.directions_run_outlined; default: return Icons.query_stats_outlined; } } String _getMetricName(String type) { switch (type.toLowerCase()) { case 'blood_pressure': return '血压'; case 'heart_rate': return '心率'; case 'glucose': return '血糖'; case 'spo2': return '血氧'; case 'weight': return '体重'; case 'exercise': return '运动计划'; default: return '健康指标'; } } static _AgentColors _agentColors(ActiveAgent agent) { return switch (agent) { ActiveAgent.consultation => _AgentColors( gradient: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)], bg: const Color(0xFFFFF4F6), border: const Color(0xFFFFDFE7), iconBg: const Color(0xFFFFEEF2), accent: const Color(0xFFFF7F98), verticalGradient: true, ), ActiveAgent.health => _AgentColors( gradient: [Color(0xFFA1FFCE), Color(0xFF69DB8F)], bg: const Color(0xFFF4FEF2), border: const Color(0xFFD4F0C8), iconBg: const Color(0xFFF0FCEF), accent: const Color(0xFF52B87A), verticalGradient: true, ), ActiveAgent.diet => _AgentColors( gradient: [Color(0xFFF6D365), Color(0xFFFDA085)], bg: const Color(0xFFFFF7F0), border: const Color(0xFFFFE8D4), iconBg: const Color(0xFFFFF0E4), accent: const Color(0xFFF89C5B), verticalGradient: true, ), ActiveAgent.medication => _AgentColors( gradient: [Color(0xFF89F7FE), Color(0xFF66A6FF)], bg: const Color(0xFFF4F6FF), border: const Color(0xFFDDE6FF), iconBg: const Color(0xFFEFF3FF), accent: const Color(0xFF4F8FF7), verticalGradient: true, ), ActiveAgent.report => _AgentColors( gradient: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)], bg: const Color(0xFFF8F5FF), border: const Color(0xFFE9E0FF), iconBg: const Color(0xFFF2EDFF), accent: const Color(0xFF9D8AF8), verticalGradient: true, ), ActiveAgent.exercise => _AgentColors( gradient: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)], bg: const Color(0xFFF0FCFF), border: const Color(0xFFD8F5FC), iconBg: const Color(0xFFEAFBFF), accent: const Color(0xFF38BDE8), verticalGradient: true, ), _ => _AgentColors( gradient: [AppColors.primary, AppColors.blueMeasure], bg: const Color(0xFFF6F3FF), border: const Color(0xFFE7E0FF), iconBg: const Color(0xFFF0EDFF), accent: AppColors.primary, ), }; } static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { return switch (agent) { ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'), ActiveAgent.diet => (LucideIcons.utensils, '拍饮食', '拍照识别食物热量和营养成分'), ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'), ActiveAgent.consultation => ( LucideIcons.messageCircle, 'AI问诊', 'AI智能问诊,描述症状获取建议', ), ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'), ActiveAgent.exercise => (LucideIcons.activity, '运动', '制定运动计划,打卡记录进度'), _ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'), }; } static String _agentTip(ActiveAgent agent) => switch (agent) { ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"', ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"', ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"', ActiveAgent.consultation => '描述您的症状和不适感,如"最近总是头晕胸闷"', ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读', ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"', _ => '直接描述您的需求,AI 会自动为您记录和分析', }; static ActiveAgent _parseAgentFromName(String? name) { switch (name) { case 'consultation': return ActiveAgent.consultation; case 'health': return ActiveAgent.health; case 'diet': return ActiveAgent.diet; case 'medication': return ActiveAgent.medication; case 'report': return ActiveAgent.report; case 'exercise': return ActiveAgent.exercise; default: return ActiveAgent.default_; } } Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) { final health = ref.watch(latestHealthProvider); final reminders = ref.watch(medicationReminderProvider); return health.when( data: (data) => _taskCardBubble(context, ref, data, reminders), loading: () => _taskCardBubble(context, ref, {}, const AsyncValue.loading()), error: (_, e) => _taskCardBubble(context, ref, {}, const AsyncValue.loading()), ); } Widget _taskCardBubble( BuildContext context, WidgetRef ref, Map healthData, AsyncValue>> reminders, ) { final now = DateTime.now(); final tasks = []; // ── 解析健康指标 ── final bp = healthData['BloodPressure']; final bpText = bp is Map ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' : null; final hr = healthData['HeartRate']; final hrText = hr is Map && hr['value'] is num ? '${hr['value']}' : null; final bs = healthData['Glucose']; final bsText = bs is Map && bs['value'] is num ? '${bs['value']}' : null; final bo = healthData['SpO2']; final boText = bo is Map && bo['value'] is num ? '${bo['value']}' : null; final wt = healthData['Weight']; final wtText = wt is Map && wt['value'] is num ? '${wt['value']}' : null; final allNull = bpText == null && hrText == null && bsText == null && boText == null && wtText == null; // ── 多指标异常检测 ── final abnormals = []; if (bp is Map && bp['systolic'] is int) { final s = bp['systolic'] as int; final d = bp['diastolic'] is int ? bp['diastolic'] as int : 0; if (s >= 140 || d >= 90) abnormals.add('血压 $s/$d 偏高'); } if (hr is Map && hr['value'] is num) { final v = (hr['value'] as num).toDouble(); if (v > 100) { abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快'); } else if (v < 60) { abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢'); } } if (bs is Map && bs['value'] is num) { final v = (bs['value'] as num).toDouble(); if (v > 6.1) abnormals.add('血糖 ${v.toStringAsFixed(1)} 偏高'); } if (bo is Map && bo['value'] is num) { final v = (bo['value'] as num).toDouble(); if (v < 95) abnormals.add('血氧 ${v.toStringAsFixed(0)}% 偏低'); } final hasAbnormal = abnormals.isNotEmpty; // ── 1. 健康指标 ── // 没记录则整行不显示(避免每天唠叨提醒) const healthIconColor = Color(0xFF3B82F6); const healthIconBg = Color(0xFFDBEAFE); if (allNull) { // 不显示 } else if (hasAbnormal) { final trailing = abnormals.length == 1 ? abnormals.first : '${abnormals.length} 项指标异常'; tasks.add( _taskRow( context, Icons.warning_amber_rounded, '健康指标', trailing: trailing, status: 'warning', iconColor: healthIconColor, iconBg: healthIconBg, onTap: () => pushRoute(ref, 'trend'), ), ); } else { // 全部正常 tasks.add( _taskRow( context, Icons.check_circle, '健康指标', trailing: '指标正常', status: 'done', iconColor: healthIconColor, iconBg: healthIconBg, onTap: () => pushRoute(ref, 'trend'), ), ); } // ── 2. 运动 ── const exIconColor = Color(0xFFF59E0B); const exIconBg = Color(0xFFFEF3C7); final exercisePlan = ref.watch(currentExercisePlanProvider); var hasExercise = false; exercisePlan.when( data: (plan) { if (plan != null) { final items = (plan['items'] as List?)?.cast>() ?? []; final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; final todayItem = items.cast?>().firstWhere( (i) => i?['scheduledDate']?.toString() == today, orElse: () => null, ); if (todayItem != null && todayItem['isRestDay'] != true) { hasExercise = true; final done = todayItem['isCompleted'] == true; final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动'; final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30'; tasks.add( _taskRow( context, Icons.directions_run, '$name $dur分钟', status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'), iconColor: exIconColor, iconBg: exIconBg, onTap: () => pushRoute(ref, 'exercisePlan'), ), ); } } }, loading: () { hasExercise = true; tasks.add( _taskRow( context, Icons.directions_run, '运动', trailing: '正在加载运动计划', status: 'pending', iconColor: exIconColor, iconBg: exIconBg, onTap: () => pushRoute(ref, 'exercisePlan'), ), ); }, error: (_, _) { hasExercise = true; tasks.add( _taskRow( context, Icons.directions_run, '运动', trailing: '运动计划加载失败', status: 'overdue', iconColor: exIconColor, iconBg: exIconBg, onTap: () => pushRoute(ref, 'exercisePlan'), ), ); }, ); if (!hasExercise) { tasks.add( _taskRow( context, Icons.directions_run, '运动', trailing: '暂无今日运动计划', status: 'pending', iconColor: exIconColor, iconBg: exIconBg, onTap: () => pushRoute(ref, 'exercisePlan'), ), ); } // ── 3. 用药打卡 ── // 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。 const medIconColor = Color(0xFFEC4899); const medIconBg = Color(0xFFFCE7F3); reminders.whenOrNull( data: (meds) { final overdueMeds = meds .where((m) => m['status'] == 'overdue') .toList(); if (overdueMeds.isEmpty) { tasks.add( _taskRow( context, Icons.medication_rounded, '用药', trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药', status: 'done', iconColor: medIconColor, iconBg: medIconBg, onTap: () => pushRoute(ref, 'medCheckIn'), ), ); return; } // 按过期时长排序——先把最严重的放前面 final now = DateTime.now(); final withDelta = overdueMeds.map((m) { final timeStr = m['scheduledTime']?.toString() ?? ''; final parts = timeStr.split(':'); DateTime? scheduled; if (parts.length >= 2) { final h = int.tryParse(parts[0]); final mn = int.tryParse(parts[1]); if (h != null && mn != null) { scheduled = DateTime(now.year, now.month, now.day, h, mn); } } final overdueHours = scheduled == null ? 0 : now.difference(scheduled).inMinutes / 60.0; return (m, overdueHours); }).toList()..sort((a, b) => b.$2.compareTo(a.$2)); // 取最严重那一项作为代表,其他用"等"省略 final first = withDelta.first; final firstName = first.$1['name']?.toString() ?? '药品'; final firstTime = first.$1['scheduledTime']?.toString() ?? ''; final firstHours = first.$2; // 过期 < 1h 提醒服药;1-4h 可补服(红色);>4h 已错过窗口(灰色提示但不强警告) final String trailing; final String status; if (firstHours < 1) { trailing = '$firstTime 该服药了'; status = 'pending'; } else if (firstHours < 4) { trailing = '$firstTime 已漏服 ${firstHours.toStringAsFixed(0)} 小时,可补服'; status = 'overdue'; } else { trailing = '$firstTime 已错过,请等下次按时服用'; status = 'overdue'; } final title = overdueMeds.length == 1 ? firstName : '$firstName 等 ${overdueMeds.length} 种'; tasks.add( _taskRow( context, Icons.medication_rounded, title, trailing: trailing, status: status, iconColor: medIconColor, iconBg: medIconBg, onTap: () => pushRoute(ref, 'medCheckIn'), ), ); }, ); return Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(28), boxShadow: AppColors.cardShadow, ), clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)], ), borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), child: Row( children: [ Container( width: 28, height: 28, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.health_and_safety, size: 16, color: AppColors.primary, ), ), const SizedBox(width: 8), const Text( '今日健康', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const Spacer(), Text( '${now.month}月${now.day}日', style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), ], ), ), Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (tasks.isEmpty) _taskRow( context, Icons.check_circle, '今日暂无待办', status: 'done', ) else ...tasks, ], ), ), ], ), ); } Widget _taskRow( BuildContext context, IconData icon, String label, { String status = 'pending', String? trailing, VoidCallback? onTap, Color? iconColor, Color? iconBg, }) { final colors = { 'done': AppTheme.success, 'warning': AppColors.warning, 'pending': AppColors.textHint, 'overdue': AppTheme.error, }; final icons = { 'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined, 'overdue': Icons.error, }; final isOverdue = status == 'overdue'; final ic = iconColor ?? AppTheme.primary; final ibg = iconBg ?? AppTheme.primaryLight; return Padding( padding: const EdgeInsets.only(bottom: 8), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 30, height: 30, decoration: BoxDecoration( color: ibg, borderRadius: BorderRadius.circular(8), ), child: Icon(icon, size: 18, color: ic), ), const SizedBox(width: 10), Expanded( child: Text( trailing ?? label, style: const TextStyle( fontSize: 16, color: AppColors.textPrimary, ), ), ), Icon( icons[status] ?? Icons.circle_outlined, size: 21, color: colors[status] ?? Colors.grey, ), ], ), ), ), ); } } class _AgentMark extends StatelessWidget { final IconData icon; final _AgentColors colors; const _AgentMark({required this.icon, required this.colors}); @override Widget build(BuildContext context) { return Container( width: 64, height: 64, decoration: BoxDecoration( gradient: LinearGradient( colors: colors.gradient, begin: colors.verticalGradient ? Alignment.topCenter : Alignment.topLeft, end: colors.verticalGradient ? Alignment.bottomCenter : Alignment.bottomRight, ), borderRadius: BorderRadius.circular(colors.verticalGradient ? 18 : 20), boxShadow: [ BoxShadow( color: colors.accent.withValues(alpha: 0.18), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: Center(child: Icon(icon, size: 32, color: Colors.white)), ); } } class _LinearAccentMark extends StatelessWidget { final _AgentColors colors; const _LinearAccentMark({required this.colors}); @override Widget build(BuildContext context) { return Container( width: 86, height: 8, decoration: BoxDecoration( borderRadius: BorderRadius.circular(999), gradient: LinearGradient( colors: colors.gradient .map((color) => color.withValues(alpha: 0.34)) .toList(), ), ), ); } } class _MiniSignalStrip extends StatelessWidget { final _AgentColors colors; const _MiniSignalStrip({required this.colors}); @override Widget build(BuildContext context) { final bars = [12.0, 20.0, 14.0, 26.0, 16.0, 22.0, 12.0]; return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ for (var i = 0; i < bars.length; i++) ...[ Container( width: 4, height: bars[i], decoration: BoxDecoration( color: colors.accent.withValues(alpha: i.isEven ? 0.34 : 0.18), borderRadius: BorderRadius.circular(99), ), ), if (i != bars.length - 1) const SizedBox(width: 4), ], ], ); } } typedef _AgentIcon = IconData; class _AgentColors { final List gradient; final Color bg; final Color border; final Color iconBg; final Color accent; final bool verticalGradient; const _AgentColors({ required this.gradient, required this.bg, required this.border, required this.iconBg, required this.accent, this.verticalGradient = false, }); } class _AgentAction { final String label; final IconData icon; final bool isWide; final String? route; final String? action; const _AgentAction({ required this.label, required this.icon, this.isWide = false, this.route, this.action, }); } class _ConfirmField { final String label; final String value; const _ConfirmField({required this.label, required this.value}); } final _agentActions = >{ ActiveAgent.health: [ _AgentAction( label: '健康概览', icon: Icons.trending_up, isWide: true, route: 'trend', ), _AgentAction( label: '蓝牙录入', icon: Icons.bluetooth, isWide: true, route: 'devices', ), ], ActiveAgent.diet: [ _AgentAction( label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, action: 'pickFoodCamera', ), _AgentAction( label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, action: 'pickFoodGallery', ), ], ActiveAgent.medication: [ _AgentAction( label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true, route: 'medications', ), _AgentAction(label: '服药打卡', icon: Icons.check_circle_outline, isWide: true), ], ActiveAgent.consultation: [], ActiveAgent.report: [ _AgentAction( label: '上传报告', icon: Icons.upload_file_outlined, isWide: true, route: 'reports', ), _AgentAction( label: '查看历史', icon: Icons.history_outlined, isWide: true, route: 'reports', ), ], ActiveAgent.exercise: [ _AgentAction( label: '查看计划', icon: Icons.calendar_month_outlined, isWide: true, route: 'exercisePlan', ), _AgentAction( label: '新建计划', icon: Icons.add_task_outlined, isWide: true, route: 'exerciseCreate', ), ], }; extension _AgentActionsExt on ActiveAgent { List<_AgentAction> get actions => _agentActions[this] ?? [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)]; } // ════════════════════════════════════════════════════════════════ // 可展开的 AI 建议小组件 // ════════════════════════════════════════════════════════════════ class _ExpandableAdvice extends StatefulWidget { final String advice; const _ExpandableAdvice({required this.advice}); @override State<_ExpandableAdvice> createState() => _ExpandableAdviceState(); } class _ExpandableAdviceState extends State<_ExpandableAdvice> { bool _expanded = false; @override Widget build(BuildContext context) { return GestureDetector( onTap: () => setState(() => _expanded = !_expanded), child: Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon( Icons.lightbulb_outline, size: 19, color: AppTheme.primary, ), const SizedBox(width: 6), const Text( 'AI 建议', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.primary, ), ), const Spacer(), Icon( _expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 21, color: AppColors.textHint, ), ], ), if (_expanded) ...[ const SizedBox(height: 10), Text( widget.advice, style: const TextStyle( fontSize: 16, color: Color(0xFF555555), height: 1.6, ), ), ], ], ), ), ); } }