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 messages, int index) => messages[index]; /// 对话消息列表 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 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 ?? {}; 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((item) => Map.from(item)) .toList() : >[]; 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 : [ { '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 healthData, AsyncValue>> reminders, AsyncValue?> exercisePlan, ) { 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; // ── 多指标异常检测 ── // 优先使用后端保存时计算出的 IsAbnormal,兜底规则必须和后端 HealthRecordRules 保持一致。 final abnormals = []; 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>() ?? []; 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, 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 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.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)]; }