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