From 5bd0155e1740000b52577f8feeb41035a501dea0 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Wed, 3 Jun 2026 21:29:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BE=A7=E8=BE=B9=E6=A0=8F=E9=87=8D?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=20-=20=E5=BD=A9=E8=89=B2=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E5=8D=A1=E7=89=87+=E5=8A=A8=E7=94=BB=E5=85=A5=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- health_app/lib/pages/home/home_page.dart | 261 +------- .../home/widgets/chat_messages_view.dart | 366 +++++++---- .../medication/medication_list_page.dart | 2 +- .../pages/profile/profile_detail_page.dart | 2 +- health_app/lib/pages/remaining_pages.dart | 49 +- health_app/lib/providers/chat_provider.dart | 7 +- health_app/lib/providers/data_providers.dart | 14 +- health_app/lib/widgets/health_drawer.dart | 598 +++++++++++++----- 8 files changed, 737 insertions(+), 562 deletions(-) diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index ff2935c..9892d7c 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import 'dart:io'; -import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/data_providers.dart'; @@ -18,49 +17,18 @@ class HomePage extends ConsumerStatefulWidget { class _HomePageState extends ConsumerState { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); - bool _taskCardsExpanded = true; String? _pickedImagePath; - double _lastScrollOffset = 0; - DateTime? _lastCollapseTime; - bool _exerciseDone = false; final Set _welcomedAgents = {}; - static final _mockFollowUps = [ - {'hospital': '协和医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 2)), 'type': '复查'}, - {'hospital': '人民医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 3)), 'type': '复诊'}, - ]; - - @override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); _textCtrl.addListener(_onTextChange); } + @override void initState() { super.initState(); } @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } - void _onTextChange() { - if (_textCtrl.text.isNotEmpty && _taskCardsExpanded) { - setState(() => _taskCardsExpanded = false); - } - } - - void _onScroll() { - if (!_scrollCtrl.hasClients) return; - final offset = _scrollCtrl.offset; - if (offset < _lastScrollOffset && _taskCardsExpanded) { - final delta = _lastScrollOffset - offset; - if (delta > 50) { - final now = DateTime.now(); - if (_lastCollapseTime == null || now.difference(_lastCollapseTime!) > const Duration(seconds: 2)) { - _lastCollapseTime = now; - setState(() => _taskCardsExpanded = false); - } - } - } - _lastScrollOffset = offset; - } - void _sendMessage() { final text = _textCtrl.text.trim(); final imagePath = _pickedImagePath; if (text.isEmpty && imagePath == null) return; _textCtrl.clear(); - setState(() { _taskCardsExpanded = false; _pickedImagePath = null; }); + setState(() => _pickedImagePath = null); if (imagePath != null) { ref.read(chatProvider.notifier).sendImage(imagePath, text); } else { @@ -131,229 +99,6 @@ class _HomePageState extends ConsumerState { return '晚上好'; } - // ═════════════════════ 今日任务(可折叠/展开) ═════════════════════ - - Widget _buildTaskCardsArea() { - final latestHealth = ref.watch(latestHealthProvider); - - if (_taskCardsExpanded) { - return latestHealth.when( - data: (data) => _taskCardContent(data), - loading: () => _taskCardContent({}), - error: (_, __) => _taskCardContent({}), - ); - } - - // 折叠状态:与展开态容器完全相同,只保留标题行 - return GestureDetector( - onTap: () => setState(() => _taskCardsExpanded = true), - behavior: HitTestBehavior.opaque, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]), - child: Row(children: [ - const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const Spacer(), - const Text('展开 ▾', style: TextStyle(fontSize: 12, color: Color(0xFF8B9CF7))), - ]), - ), - ); - } - - Widget _taskCardContent(Map healthData) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const Spacer(), - GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Text('收起', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - const Text('∧', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - ])), - ]), - const SizedBox(height: 10), - ..._getTodayTasks(healthData), - ]), - ); - } - - List _getTodayTasks(Map healthData) { - final now = DateTime.now(); - final tasks = []; - - // 1. 数据摘要卡片(有今日指标数据时显示) - final summaryParts = []; - final bp = healthData['BloodPressure']; - if (bp is Map) { - final s = bp['systolic']; - final d = bp['diastolic']; - if (s != null && d != null) summaryParts.add('血压 $s/$d'); - } - final hr = healthData['HeartRate']; - if (hr is int) summaryParts.add('心率 $hr'); - final bs = healthData['BloodSugar']; - if (bs is num) summaryParts.add('血糖 $bs'); - final bo = healthData['BloodOxygen']; - if (bo is num) summaryParts.add('血氧 $bo'); - final wt = healthData['Weight']; - if (wt is num) summaryParts.add('体重 $wt'); - - if (summaryParts.isNotEmpty) { - tasks.add(_summaryCard(summaryParts)); - } - - // 2. 用药提醒(从后端拉取真实数据) - final reminders = ref.watch(medicationReminderProvider); - reminders.whenData((meds) { - for (final m in meds) { - final name = m['name'] ?? ''; - final dosage = m['dosage'] ?? ''; - final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? ''; - final medOverdue = now.hour >= 8; - tasks.add(_taskRow( - icon: Icons.medication_rounded, - label: '$name $dosage ($times)', - status: 'pending', - isOverdue: medOverdue, - onTap: () => _handleMedicationCheck, - )); - } - }); - - // 无提醒时显示默认用药卡片 - if (tasks.length <= (summaryParts.isNotEmpty ? 1 : 0)) { - tasks.add(_taskRow( - icon: Icons.medication_rounded, - label: '暂无用药提醒', - status: 'pending', - onTap: null, - )); - } - - // 3. 运动卡片(超时变红) - final exOverdue = now.hour >= 18 && !_exerciseDone; - tasks.add(_taskRow( - icon: Icons.directions_run, - label: '今日待运动:散步 30 分钟', - status: _exerciseDone ? 'done' : 'pending', - isOverdue: exOverdue, - onTap: _exerciseDone ? null : () => setState(() => _exerciseDone = true), - )); - - // 4. 测量卡片 - tasks.add(_taskRow( - icon: Icons.today, - label: '今日测量:血压', - status: 'pending', - onTap: () => _textCtrl.text = '血压 ', - )); - - // 5. 异常指标 - tasks.addAll(_buildAbnormalRows(healthData)); - - // 6. 复查提醒(未来3天内有复查安排时显示) - final upcomingFollowUps = _mockFollowUps.where((f) { - final date = f['date'] as DateTime; - return date.difference(now).inDays <= 3 && date.isAfter(now); - }).toList(); - - if (upcomingFollowUps.isNotEmpty) { - tasks.add(_followUpCard(upcomingFollowUps.first)); - } - - return tasks; - } - - List _buildAbnormalRows(Map healthData) { - final rows = []; - final bp = healthData['BloodPressure']; - if (bp is Map) { final s = bp['systolic']; if (s is int && s >= 140) rows.add(_taskRow(icon: Icons.warning_amber_rounded, label: '血压 $s/${bp['diastolic'] ?? '--'} 偏高', status: 'warning', onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}))); } - return rows; - } - - Widget _summaryCard(List parts) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => pushRoute(ref, 'trend'), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: const Color(0xFFF1F8E9), - borderRadius: BorderRadius.circular(10), - ), - child: Row(children: [ - const Icon(Icons.check_circle, size: 18, color: Color(0xFF43A047)), - const SizedBox(width: 10), - Expanded(child: Text( - '今日已记录:${parts.join('、')}', - style: const TextStyle(fontSize: 13, color: Color(0xFF333333)), - )), - ]), - ), - ), - ); - } - - Widget _followUpCard(Map followUp) { - final date = followUp['date'] as DateTime; - final now = DateTime.now(); - final diff = date.difference(now).inDays; - String dateLabel; - if (diff == 0) { - dateLabel = '今天'; - } else if (diff == 1) { - dateLabel = '明天'; - } else if (diff == 2) { - dateLabel = '后天'; - } else { - dateLabel = '$diff天后'; - } - - return _taskRow( - icon: Icons.event_available, - label: '📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}', - status: 'pending', - onTap: () => pushRoute(ref, 'followups'), - ); - } - - Widget _taskRow({required IconData icon, required String label, required String status, VoidCallback? onTap, bool isOverdue = false}) { - final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E)}; - final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined}; - final effectiveStatus = isOverdue ? 'warning' : status; - return Padding( - padding: const EdgeInsets.only(bottom: 10), - 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: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF8B9CF7))), - const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))), - Icon(icons[effectiveStatus], size: 18, color: colors[effectiveStatus] ?? Colors.grey), - ]), - ), - ), - ); - } - - void _handleMedicationCheck() async { - await ref.read(medicationServiceProvider).confirm(''); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF8B9CF7))); - } - // ═════════════════════ 智能体选择条(常驻) ═════════════════════ static final _agentDefs = [ @@ -372,7 +117,7 @@ class _HomePageState extends ConsumerState { child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: _agentDefs.length, - separatorBuilder: (_, __) => const SizedBox(width: 6), + separatorBuilder: (_, i) => const SizedBox(width: 6), itemBuilder: (_, i) { final (agent, label, icon) = _agentDefs[i]; final isActive = selected == agent; diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 34236ca..95d6bf5 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -55,12 +55,6 @@ class ChatMessagesView extends ConsumerWidget { // ─── 消息分发 ───────────────────────────────────────────── Widget _buildMessageContent(BuildContext context, WidgetRef ref, ChatMessage msg, ChatState chatState) { - final isUser = msg.isUser; - - if (!isUser && chatState.isStreaming && msg.content.isEmpty) { - return _buildThinkingBubble(context, chatState.thinkingText); - } - switch (msg.type) { case MessageType.agentWelcome: final storedAgent = _parseAgentFromName(msg.metadata?['agent'] as String?); @@ -78,6 +72,10 @@ class ChatMessagesView extends ConsumerWidget { case MessageType.quickOptions: return _buildQuickOptionsCard(context, msg); default: + // 只有当前正在流式回复的文本消息才显示"正在分析" + if (!msg.isUser && chatState.isStreaming && msg.content.isEmpty) { + return _buildThinkingBubble(context, chatState.thinkingText); + } return _buildTextBubble(context, msg); } } @@ -90,6 +88,7 @@ class ChatMessagesView extends ConsumerWidget { final info = _agentInfo(agent); final actions = agent.actions; final screenWidth = MediaQuery.of(context).size.width; + final colors = _agentColors(agent); return Align( alignment: Alignment.centerLeft, @@ -97,23 +96,23 @@ class ChatMessagesView extends ConsumerWidget { margin: const EdgeInsets.only(bottom: 12), constraints: BoxConstraints(maxWidth: screenWidth * 0.92), decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), + color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ - BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 16, offset: const Offset(0, 4)), + BoxShadow(color: colors.gradient[0].withAlpha(30), blurRadius: 16, offset: const Offset(0, 4)), ], ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── 紫色渐变头部 ── + // ── 渐变色头部 ── Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 24, 16, 20), - decoration: const BoxDecoration( + decoration: BoxDecoration( gradient: LinearGradient( - colors: [Color(0xFFA8B5FA), Color(0xFF8B9CF7), Color(0xFF5C70D6)], + colors: colors.gradient, begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -143,23 +142,11 @@ class ChatMessagesView extends ConsumerWidget { color: Colors.white.withAlpha(25), borderRadius: BorderRadius.circular(10), ), - child: Text(info.$3, style: const TextStyle(fontSize: 12, color: Color(0xFFD8DCFD))), + child: Text(info.$3, style: const TextStyle(fontSize: 12, color: Color(0xFFE8E6FF))), ), ], ), ), - GestureDetector( - onTap: () {}, - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.white.withAlpha(20), - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, size: 16, color: Color(0xFFD8DCFD)), - ), - ), ], ), ), @@ -172,7 +159,7 @@ class ChatMessagesView extends ConsumerWidget { runSpacing: 10, children: agent == ActiveAgent.consultation ? _buildDoctorCards(screenWidth, ref) - : actions.map((a) => _agentActionBtn(a, screenWidth, context, ref)).toList(), + : actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(), ), ), @@ -183,7 +170,7 @@ class ChatMessagesView extends ConsumerWidget { ); } - Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref) { + Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref, _AgentColors colors) { return InkWell( onTap: () { if (a.action == 'createPlan') { @@ -199,14 +186,15 @@ class ChatMessagesView extends ConsumerWidget { pushRoute(ref, a.route!); } } - }, borderRadius: BorderRadius.circular(14), + }, + borderRadius: BorderRadius.circular(14), child: Container( width: ((screenWidth - 72) / (a.isWide ? 2 : 3)) - 10, padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 8), decoration: BoxDecoration( - color: const Color(0xFFF7F5FF), + color: colors.bg, borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFD8DCFD), width: 1), + border: Border.all(color: colors.border, width: 1), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -215,10 +203,10 @@ class ChatMessagesView extends ConsumerWidget { width: 38, height: 38, decoration: BoxDecoration( - color: const Color(0xFFEDEAFF), + color: colors.iconBg, borderRadius: BorderRadius.circular(11), ), - child: Icon(a.icon, size: 20, color: const Color(0xFF8B9CF7)), + child: Icon(a.icon, size: 20, color: colors.accent), ), const SizedBox(height: 7), Text(a.label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF333333))), @@ -644,16 +632,7 @@ class ChatMessagesView extends ConsumerWidget { ]), ); }), - ] else ...[ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(12)), - child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.hourglass_empty, size: 18, color: Color(0xFF999999)), - SizedBox(width: 8), - Text('正在分析食物中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), - ]), - ), + const SizedBox(height: 6), ], // ── AI 建议 ── @@ -964,34 +943,48 @@ class ChatMessagesView extends ConsumerWidget { boxShadow: isUser ? [] : [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))], ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (hasImage) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: imageUrl != null - ? Image.network(imageUrl, fit: BoxFit.cover, width: double.infinity, errorBuilder: (_, __, ___) => _buildLocalFallback(localPath)) - : localPath != null - ? Image.file(File(localPath), fit: BoxFit.cover, width: double.infinity) - : null, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 文字内容 + if (isUser) + Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4)) + else + MarkdownBody( + data: msg.content, + selectable: true, + styleSheet: MarkdownStyleSheet( + p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5), + h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + h2: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + code: const TextStyle(fontSize: 14, backgroundColor: Color(0xFFF0F2FF)), + ), ), - ), - if (isUser) - Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4)) - else - MarkdownBody( - data: msg.content, - selectable: true, - styleSheet: MarkdownStyleSheet( - p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5), - h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), - h2: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), - code: const TextStyle(fontSize: 14, backgroundColor: Color(0xFFF0F2FF)), + + // 图片缩略图(在文字下方) + 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: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.image, size: 24, color: Color(0xFFBBBBBB)), + )) + : const SizedBox.shrink(), + ), + ), + ), ), - ), - if (!isUser && msg.content.isNotEmpty) + + if (!isUser && msg.content.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 10), child: Row(children: [ @@ -1008,18 +1001,6 @@ class ChatMessagesView extends ConsumerWidget { ); } - Widget _buildLocalFallback(String? localPath) { - if (localPath != null) { - final file = File(localPath); - return Image.file(file, fit: BoxFit.cover, width: double.infinity); - } - return Container( - height: 100, - color: const Color(0xFFEEEEEE), - child: const Center(child: Icon(Icons.broken_image, size: 40, color: Color(0xFFBDBDBD))), - ); - } - // ═══════════════════════════════════════════════════════════ // 公共组件:通用按钮 // ═══════════════════════════════════════════════════════════ @@ -1063,6 +1044,26 @@ class ChatMessagesView extends ConsumerWidget { // 工具方法 // ═══════════════════════════════════════════════════════════ + 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: 18)), + )), + ]), + ), + ); + } + String _formatTime(DateTime dt) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -1206,6 +1207,60 @@ class ChatMessagesView extends ConsumerWidget { } } + static _AgentColors _agentColors(ActiveAgent agent) { + return switch (agent) { + ActiveAgent.consultation => _AgentColors( + gradient: [const Color(0xFFC5D5F8), const Color(0xFFA0B8F0), const Color(0xFF7B98E0)], + bg: const Color(0xFFF0F4FF), + border: const Color(0xFFD8E0FA), + iconBg: const Color(0xFFE4ECFC), + accent: const Color(0xFF7B98E0), + ), + ActiveAgent.health => _AgentColors( + gradient: [const Color(0xFFB8E6CF), const Color(0xFF8ED4AE), const Color(0xFF5FB88D)], + bg: const Color(0xFFF0FAF4), + border: const Color(0xFFD0ECD8), + iconBg: const Color(0xFFE4F8EC), + accent: const Color(0xFF5FB88D), + ), + ActiveAgent.diet => _AgentColors( + gradient: [const Color(0xFFFFD8B8), const Color(0xFFFFC896), const Color(0xFFF0A060)], + bg: const Color(0xFFFFF6F0), + border: const Color(0xFFFFE8D4), + iconBg: const Color(0xFFFFEEDC), + accent: const Color(0xFFF0A060), + ), + ActiveAgent.medication => _AgentColors( + gradient: [const Color(0xFFFFD4E0), const Color(0xFFFFB8CC), const Color(0xFFE898A8)], + bg: const Color(0xFFFFF0F4), + border: const Color(0xFFFFE0E8), + iconBg: const Color(0xFFFFE8EE), + accent: const Color(0xFFE898A8), + ), + ActiveAgent.report => _AgentColors( + gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)], + bg: const Color(0xFFF8F4FF), + border: const Color(0xFFECE4F8), + iconBg: const Color(0xFFF0E8FC), + accent: const Color(0xFFA898D8), + ), + ActiveAgent.exercise => _AgentColors( + gradient: [const Color(0xFFB8E0E0), const Color(0xFF90D0D0), const Color(0xFF68B4B4)], + bg: const Color(0xFFF0FAFA), + border: const Color(0xFFD4ECEC), + iconBg: const Color(0xFFE4F4F4), + accent: const Color(0xFF68B4B4), + ), + _ => _AgentColors( + gradient: [const Color(0xFFC5D0F8), const Color(0xFFA0B0F0), const Color(0xFF7B90E0)], + bg: const Color(0xFFF5F5FF), + border: const Color(0xFFE0E0F8), + iconBg: const Color(0xFFEDEDFC), + accent: const Color(0xFF7B90E0), + ), + }; + } + static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { return switch (agent) { ActiveAgent.health => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'), @@ -1232,47 +1287,131 @@ class ChatMessagesView extends ConsumerWidget { Widget _buildTaskCardInChat(BuildContext context, WidgetRef ref) { final health = ref.watch(latestHealthProvider); + final reminders = ref.watch(medicationReminderProvider); return health.when( - data: (data) => _taskCardBubble(data), - loading: () => _taskCardBubble({}), - error: (_, __) => _taskCardBubble({}), + data: (data) => _taskCardBubble(context, ref, data, reminders), + loading: () => _taskCardBubble(context, ref, {}, const AsyncValue.loading()), + error: (_, e) => _taskCardBubble(context, ref, {}, const AsyncValue.loading()), ); } - Widget _taskCardBubble(Map data) { - final bp = data['BloodPressure']; - final bpText = bp is Map ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' : '--'; - final hr = data['HeartRate']; - final hrText = hr is Map && hr['value'] != null ? '${hr['value']}' : '--'; - final gl = data['Glucose']; - final glText = gl is Map && gl['value'] != null ? '${gl['value']}' : '--'; - final sp = data['SpO2']; - final spText = sp is Map && sp['value'] != null ? '${sp['value']}' : '--'; + Widget _taskCardBubble(BuildContext context, WidgetRef ref, Map healthData, AsyncValue>> reminders) { + final now = DateTime.now(); + final tasks = []; + + // 1. 健康数据摘要行 + 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; + + if (!allNull) { + tasks.add(_taskRow(context, Icons.check_circle, '今日已记录', trailing: [ + if (bpText != null) '血压 $bpText', + if (hrText != null) '心率 $hrText', + if (bsText != null) '血糖 $bsText', + if (boText != null) '血氧 $boText', + if (wtText != null) '体重 $wtText', + ].join(' · '), status: 'done', onTap: () => pushRoute(ref, 'trend'))); + } + + // 2. 用药提醒 + reminders.whenOrNull(data: (meds) { + for (final m in meds) { + final name = m['name'] ?? ''; + final dosage = m['dosage'] ?? ''; + final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? ''; + final medOverdue = now.hour >= 8; + tasks.add(_taskRow( + context, Icons.medication_rounded, '$name $dosage ($times)', + status: medOverdue ? 'overdue' : 'pending', + onTap: () {}, + )); + } + }); + if (tasks.length <= (allNull ? 0 : 1)) { + tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending')); + } + + // 3. 运动 + final exOverdue = now.hour >= 18; + tasks.add(_taskRow( + context, Icons.directions_run, '今日待运动:散步 30 分钟', + status: exOverdue ? 'overdue' : 'pending', + onTap: () => pushRoute(ref, 'exercisePlan'), + )); + + // 4. 测量 + tasks.add(_taskRow( + context, Icons.today, '今日测量:血压', + status: 'pending', + onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}), + )); + + // 5. 异常指标 + if (bp is Map) { + final s = bp['systolic']; + if (s is int && s >= 140) { + tasks.add(_taskRow( + context, Icons.warning_amber_rounded, '血压 $s/${bp['diastolic'] ?? '--'} 偏高', + status: 'warning', + onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}), + )); + } + } return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], + ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [Icon(Icons.today, size: 18, color: const Color(0xFF8B9CF7)), const SizedBox(width: 8), const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), - const SizedBox(height: 10), - Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _miniMetric('血压', bpText, Icons.favorite), - _miniMetric('心率', hrText, Icons.monitor_heart), - _miniMetric('血糖', glText, Icons.bloodtype), - _miniMetric('血氧', spText, Icons.air), + Row(children: [ + Icon(Icons.today, size: 18, color: const Color(0xFF8B9CF7)), + const SizedBox(width: 8), + const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), ]), + const SizedBox(height: 10), + ...tasks, ]), ); } - Widget _miniMetric(String label, String value, IconData icon) { - return Column(children: [ - Icon(icon, size: 20, color: const Color(0xFF8B9CF7)), - const SizedBox(height: 4), - Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), - Text(label, style: const TextStyle(fontSize: 10, color: Color(0xFF999999))), - ]); + Widget _taskRow(BuildContext context, IconData icon, String label, {String status = 'pending', String? trailing, VoidCallback? onTap}) { + final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E), 'overdue': const Color(0xFFE53935)}; + final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined, 'overdue': Icons.error}; + final isOverdue = status == 'overdue'; + 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: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF8B9CF7))), + const SizedBox(width: 10), + Expanded(child: Text(trailing ?? label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))), + Icon(icons[status] ?? Icons.circle_outlined, size: 18, color: colors[status] ?? Colors.grey), + ]), + ), + ), + ); } } @@ -1282,6 +1421,21 @@ class ChatMessagesView extends ConsumerWidget { 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; diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index e453d92..93e0fb5 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -48,7 +48,7 @@ class MedicationListPage extends ConsumerWidget { ); }, loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))), - error: (_, __) => _empty(context), + error: (_, e) => _empty(context), )), _buildReminderBar(), ]), diff --git a/health_app/lib/pages/profile/profile_detail_page.dart b/health_app/lib/pages/profile/profile_detail_page.dart index b9577af..b12ee64 100644 --- a/health_app/lib/pages/profile/profile_detail_page.dart +++ b/health_app/lib/pages/profile/profile_detail_page.dart @@ -38,7 +38,7 @@ class ProfileDetailPage extends ConsumerWidget { const SizedBox(height: 4), Text('(最近测量)', style: TextStyle(fontSize: 13, color: Colors.grey[500])), const SizedBox(height: 16), - healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF8B9CF7)))), error: (_, __) => _buildMetricsEmpty()), + healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF8B9CF7)))), error: (_, e) => _buildMetricsEmpty()), ]), ); } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 451214e..8bf607c 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -83,7 +83,7 @@ class ExercisePlanPage extends ConsumerWidget { ]); }, loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))), - error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'), + error: (_, e) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'), ), ); } @@ -135,25 +135,34 @@ class ExercisePlanPage extends ConsumerWidget { } void _createDefaultPlan(WidgetRef ref, BuildContext context) async { - final service = ref.read(exerciseServiceProvider); - final today = DateTime.now(); - final monday = today.subtract(Duration(days: today.weekday - 1)); - final items = List.generate(7, (i) => { - 'dayOfWeek': i, - 'exerciseType': i == 2 || i == 5 ? '休息' : '散步', - 'durationMinutes': i == 2 || i == 5 ? 0 : 30, - 'isRestDay': i == 2 || i == 5, - }); - await service.createPlan({ - 'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}', - 'items': items, - }); - ref.invalidate(currentExercisePlanProvider); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('运动计划已创建 ✅'), - backgroundColor: Color(0xFF8B9CF7), - )); + try { + final service = ref.read(exerciseServiceProvider); + final today = DateTime.now(); + final monday = today.subtract(Duration(days: today.weekday - 1)); + final items = List.generate(7, (i) => { + 'dayOfWeek': i, + 'exerciseType': i == 2 || i == 5 ? '休息' : '散步', + 'durationMinutes': i == 2 || i == 5 ? 0 : 30, + 'isRestDay': i == 2 || i == 5, + }); + await service.createPlan({ + 'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}', + 'items': items, + }); + ref.invalidate(currentExercisePlanProvider); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('运动计划已创建'), + backgroundColor: Color(0xFF43A047), + )); + } catch (e) { + // 后端不可用时,直接使用本地 mock 数据 + ref.invalidate(currentExercisePlanProvider); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已创建本地计划(离线模式)'), backgroundColor: const Color(0xFFFF9800)), + ); + } } void _checkIn(WidgetRef ref, String itemId, BuildContext context) async { diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 64d8b32..de2bd10 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -171,17 +171,18 @@ class ChatNotifier extends Notifier { // 上传失败:保留本地路径,仍然可以本地显示 } - // 更新消息元数据(上传成功则替换为远程 URL) - final finalUrl = uploadedUrl ?? imagePath; + // 更新消息元数据(保留本地路径 + 添加远程URL) final updatedMsgs = state.messages.toList(); final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id); if (idx >= 0) { + final meta = {'localImagePath': imagePath}; + if (uploadedUrl != null) meta['imageUrl'] = uploadedUrl; updatedMsgs[idx] = ChatMessage( id: userMsg.id, role: 'user', content: userMsg.content, createdAt: userMsg.createdAt, - metadata: {'imageUrl': finalUrl}, + metadata: meta, ); state = state.copyWith(messages: updatedMsgs); } diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 21d88f1..0fc0fd9 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -96,7 +96,19 @@ final currentExercisePlanProvider = FutureProvider?>((ref) try { return await service.getCurrentPlan().timeout(const Duration(seconds: 8)); } catch (_) { - return null; + final today = DateTime.now(); + final monday = today.subtract(Duration(days: today.weekday - 1)); + return { + 'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}', + 'items': List.generate(7, (i) => { + 'id': 'local_$i', + 'dayOfWeek': i, + 'exerciseType': i == 2 || i == 5 ? '休息' : '散步', + 'durationMinutes': i == 2 || i == 5 ? 0 : 30, + 'isRestDay': i == 2 || i == 5, + 'isCompleted': false, + }), + }; } }); diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index d6f6d8c..63571e5 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -5,7 +5,7 @@ import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; import '../providers/chat_provider.dart'; -/// 侧滑抽屉——健康概览 + 历史对话 + 菜单 +/// 侧滑抽屉——彩色分区卡片式设计 class HealthDrawer extends ConsumerWidget { const HealthDrawer({super.key}); @@ -17,244 +17,482 @@ class HealthDrawer extends ConsumerWidget { final conversations = ref.watch(conversationListProvider); return Drawer( - width: MediaQuery.of(context).size.width * 0.8, + width: MediaQuery.of(context).size.width * 0.82, + backgroundColor: const Color(0xFFFAFBFE), child: SafeArea( child: ListView( - padding: EdgeInsets.zero, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), children: [ - // 用户信息 - Container( - padding: const EdgeInsets.all(20), + // ════════════ 用户区 ════════════ + _SectionCard( + color: const Color(0xFF635BFF), + gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)], + child: Padding( + padding: const EdgeInsets.all(18), + child: Row(children: [ + GestureDetector( + onTap: () => pushRoute(ref, 'profile'), + child: Container( + width: 52, height: 52, + decoration: BoxDecoration( + gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]), + shape: BoxShape.circle, + border: Border.all(color: Colors.white30, width: 1.5), + ), + child: user?.avatarUrl != null + ? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar())) + : _defaultAvatar(), + ), + ), + const SizedBox(width: 14), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)), + const SizedBox(height: 2), + Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)), + ], + )), + Icon(Icons.chevron_right, size: 18, color: Colors.white54), + ]), + ), + ), + + const SizedBox(height: 10), + + // ════════════ 健康概览区 ════════════ + _SectionCard( + color: const Color(0xFFE8F0FE), + gradientColors: null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () => pushRoute(ref, 'profile'), - child: CircleAvatar( - radius: 28, - backgroundColor: const Color(0xFFF0F2FF), - child: Icon(Icons.person, size: 32, color: Theme.of(context).colorScheme.primary), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF635BFF).withAlpha(15), + borderRadius: BorderRadius.circular(6), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.monitor_heart_rounded, size: 13, color: const Color(0xFF635BFF)), + SizedBox(width: 4), + Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))), + ]), + ), + const Spacer(), + GestureDetector( + onTap: () => pushRoute(ref, 'trend'), + child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 11, color: Color(0xFF888888)))), + ), + ]), + ), + latestHealth.when( + data: (data) => Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 14), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetricTile(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), accentColor: const Color(0xFFFF6B6B), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), + _MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), unit: '', accentColor: const Color(0xFFFF9F43), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), + _MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), unit: '', accentColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), + _MetricTile(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), unit: '%', accentColor: const Color(0xFF4D96FF), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), + _MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), unit: 'kg', accentColor: const Color(0xFFA55EEA), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})), + ], + ), + ), + loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF))))), + error: (Object err, StackTrace st) => Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 14), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetricTile(icon: Icons.favorite_rounded, label: '血压', value: '--', accentColor: const Color(0xFFFF6B6B)), + _MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: '--', accentColor: const Color(0xFFFF9F43)), + _MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: '--', accentColor: const Color(0xFF26C281)), + _MetricTile(icon: Icons.air_outlined, label: '血氧', value: '--', accentColor: const Color(0xFF4D96FF)), + _MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: '--', accentColor: const Color(0xFFA55EEA)), + ], + ), ), ), - const SizedBox(height: 12), - Text(user?.name ?? '未设置昵称', style: Theme.of(context).textTheme.titleMedium), - if (user != null) const SizedBox(height: 4), - Text(user?.phone ?? '', style: Theme.of(context).textTheme.labelMedium), ], ), ), - // 健康概览——接真实数据 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Text('健康概览', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), - ), - latestHealth.when( - data: (data) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _HealthMetricChip(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), - _HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), - _HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), - _HealthMetricChip(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), - _HealthMetricChip(icon: Icons.monitor_weight, label: '体重', value: _metricText(data['Weight'], 'kg'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})), - ], - ), - ), - loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))), - error: (_, _) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - const _HealthMetricChip(icon: Icons.favorite, label: '血压', value: '--'), - const _HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: '--'), - const _HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: '--'), - const _HealthMetricChip(icon: Icons.air, label: '血氧', value: '--'), - const _HealthMetricChip(icon: Icons.monitor_weight, label: '体重', value: '--'), - ], - ), - ), - ), - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Text('功能', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), - ), - _DrawerItem(icon: Icons.description, label: '报告管理', onTap: () => pushRoute(ref, 'reports')), - _DrawerItem(icon: Icons.calendar_today, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')), - _DrawerItem(icon: Icons.restaurant, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')), - _DrawerItem(icon: Icons.event_note, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), - const Divider(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Row(children: [ - Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), - const Spacer(), - TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF8B9CF7)))), - ]), - ), - conversations.when( - data: (items) { - if (items.isEmpty) { - return const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 13)))); - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: items.map((item) => _ConversationItem(item: item, ref: ref)).toList(), + const SizedBox(height: 10), + + // ════════════ 功能区(横向排布)════════════ + _SectionCard( + color: const Color(0xFFFDF6EC), + gradientColors: null, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF0A060).withAlpha(15), + borderRadius: BorderRadius.circular(6), + ), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.apps_rounded, size: 13, color: Color(0xFFF0A060)), + SizedBox(width: 4), + Text('功能', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))), + ]), + ), + ]), ), - ); - }, - loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)), - error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _FeatureChip(icon: Icons.description_outlined, label: '报告管理', bgColor: const Color(0xFFFFEDE0), iconColor: const Color(0xFFF0A060), onTap: () => pushRoute(ref, 'reports')), + _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', bgColor: const Color(0xFFE0F0E0), iconColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'calendar')), + _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', bgColor: const Color(0xFFFFE8E0), iconColor: const Color(0xFFFF8C42), onTap: () => pushRoute(ref, 'dietRecords')), + _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', bgColor: const Color(0xFFE8E0FF), iconColor: const Color(0xFF8B6CF7), onTap: () => pushRoute(ref, 'followups')), + ], + ), + ], + ), ), + ), - const Divider(), - _DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')), + const SizedBox(height: 10), + + // ════════════ 历史对话区 ════════════ + _SectionCard( + color: const Color(0xFFF0F4FF), + gradientColors: null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF4D96FF).withAlpha(15), + borderRadius: BorderRadius.circular(6), + ), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.history_rounded, size: 13, color: Color(0xFF4D96FF)), + SizedBox(width: 4), + Text('历史对话', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4D96FF))), + ]), + ), + const Spacer(), + GestureDetector( + onTap: () => ref.invalidate(conversationListProvider), + child: const Padding(padding: EdgeInsets.all(4), child: Icon(Icons.refresh, size: 15, color: Color(0xFFAAAAAA))), + ), + ]), + ), + _buildConversationList(ref, conversations), + ], + ), + ), + + const SizedBox(height: 10), + + // ════════════ 设置区 ════════════ + _SectionCard( + color: const Color(0xFFF5F5F7), + gradientColors: null, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => pushRoute(ref, 'settings'), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Row(children: [ + Container( + width: 34, height: 34, + decoration: BoxDecoration( + color: const Color(0xFFEEEEEE), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.settings_outlined, size: 18, color: Color(0xFF666666)), + ), + const SizedBox(width: 12), + const Expanded(child: Text('设置', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF333333)))), + const Icon(Icons.chevron_right, size: 16, color: Color(0xFFCCCCCC)), + ]), + ), + ), + ), + ), + + const SizedBox(height: 6), ], ), ), ); } + static Widget _defaultAvatar() => const Icon(Icons.person, size: 26, color: Colors.white70); + String _bpText(dynamic bp) { if (bp == null) return '--'; if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'; return '--'; } - String _metricText(dynamic metric, String unit) { + String _metricVal(dynamic metric) { if (metric == null) return '--'; - if (metric is Map) { - final v = metric['value']; - return v != null ? '$v $unit' : '--'; - } - return '--'; + if (metric is Map) { final v = metric['value']; return v?.toString() ?? '--'; } + return metric.toString(); + } + + Widget _buildConversationList(WidgetRef ref, AsyncValue> conversations) { + return conversations.when( + data: (items) { + if (items.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('暂无历史对话', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)), + ), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: items.map((item) => _ConversationItem(item: item)).toList(), + ), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF4D96FF)), + ), + ), + ), + error: (Object err, StackTrace st) => const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('加载失败', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)), + ), + ), + ); } } -class _DrawerItem extends StatelessWidget { - final IconData icon; final String label; final VoidCallback onTap; - const _DrawerItem({required this.icon, required this.label, required this.onTap}); - @override Widget build(BuildContext context) => ListTile(leading: Icon(icon, size: 20, color: const Color(0xFF666666)), title: Text(label, style: const TextStyle(fontSize: 16)), onTap: onTap, dense: true); +// ═══════════════════════════════════════════════════════════════ +// 分区卡片容器 —— 带圆角、阴影和微动效 +// ═══════════════════════════════════════════════════════════════ + +class _SectionCard extends StatelessWidget { + final Widget child; + final Color color; + final List? gradientColors; + + const _SectionCard({required this.child, required this.color, this.gradientColors}); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + builder: (context, value, child) => Transform.translate( + offset: Offset(0, 8 * (1 - value)), + child: Opacity(opacity: value, child: child), + ), + child: Container( + decoration: BoxDecoration( + color: gradientColors == null ? color : null, + gradient: gradientColors != null ? LinearGradient( + colors: gradientColors!, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) : null, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: (gradientColors?.first ?? color).withAlpha(25), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: child, + ), + ); + } } -class _HealthMetricChip extends StatelessWidget { +// ═══════════════════════════════════════════════════════════════ +// 健康指标小方块 +// ═══════════════════════════════════════════════════════════════ + +class _MetricTile extends StatelessWidget { final IconData icon; final String label; final String value; + final String? unit; + final Color accentColor; final VoidCallback? onTap; - const _HealthMetricChip({required this.icon, required this.label, required this.value, this.onTap}); + const _MetricTile({ + required this.icon, + required this.label, + required this.value, + this.unit, + required this.accentColor, + this.onTap, + }); @override - Widget build(BuildContext context) => GestureDetector( - onTap: onTap, - child: Container( - width: 80, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFF0F2FF)), + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: ((MediaQuery.of(context).size.width * 0.82 - 48) / 3).floorToDouble(), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: accentColor.withAlpha(30)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 28, height: 28, + decoration: BoxDecoration( + color: accentColor.withAlpha(15), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 15, color: accentColor), + ), + const SizedBox(height: 4), + Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF1A1A1A))), + Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])), + ], + ), ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: const Color(0xFF8B9CF7)), - const SizedBox(width: 4), - Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[600])), - Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - ]), - ]), - ), - ); + ); + } } -class _ConversationItem extends ConsumerWidget { - final ConversationItem item; - final WidgetRef ref; - const _ConversationItem({required this.item, required this.ref}); +// ═══════════════════════════════════════════════════════════════ +// 功能按钮(横向) +// ═══════════════════════════════════════════════════════════════ + +class _FeatureChip extends StatelessWidget { + final IconData icon; + final String label; + final Color bgColor; + final Color iconColor; + final VoidCallback onTap; + + const _FeatureChip({ + required this.icon, + required this.label, + required this.bgColor, + required this.iconColor, + required this.onTap, + }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 17, color: iconColor), + const SizedBox(width: 6), + Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: iconColor.withAlpha(220))), + ]), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════ +// 历史对话项 +// ═══════════════════════════════════════════════════════════════ + +class _ConversationItem extends StatelessWidget { + final ConversationItem item; + + const _ConversationItem({required this.item}); + + @override + Widget build(BuildContext context) { + final colors = _conversationColors(item.agent); return Container( - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + margin: const EdgeInsets.symmetric(vertical: 2), decoration: BoxDecoration( - color: const Color(0xFFF8F9FC), + color: Colors.white, borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.$1.withAlpha(80)), ), child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), leading: Container( - width: 28, - height: 28, + width: 32, height: 32, decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), + gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]), borderRadius: BorderRadius.circular(8), ), - child: Icon(_getAgentIcon(item.agent), size: 14, color: const Color(0xFF8B9CF7)), + child: Icon(_getAgentIcon(item.agent), size: 15, color: colors.$2), ), - title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), - subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: Colors.grey[500])), + title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))), + subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 9, color: Color(0xFFCCCCCC))), - PopupMenuButton( - icon: const Icon(Icons.more_vert, size: 12, color: Color(0xFFCCCCCC)), - itemBuilder: (_) => [ - const PopupMenuItem(value: 1, child: Text('继续聊')), - const PopupMenuItem(value: 2, child: Text('删除')), - ], - onSelected: (v) async { - if (v == 1) { - ref.read(chatProvider.notifier).setAgent(item.agent); - Navigator.pop(context); - } else if (v == 2) { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('删除对话'), - content: const Text('确定删除该对话?'), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')), - ], - ), - ); - if (ok == true) { - ref.invalidate(conversationListProvider); - } - } - }, - ), + Text(_formatTime(item.updatedAt), style: TextStyle(fontSize: 9, color: Colors.grey[400])), + const SizedBox(height: 2), + Icon(Icons.chevron_right, size: 12, color: Colors.grey[300]), ], ), - onTap: () { - ref.read(chatProvider.notifier).setAgent(item.agent); - Navigator.pop(context); - }, dense: true, + visualDensity: VisualDensity.compact, ), ); } IconData _getAgentIcon(ActiveAgent agent) { - switch (agent) { - case ActiveAgent.health: return Icons.health_and_safety; - case ActiveAgent.diet: return Icons.restaurant; - case ActiveAgent.medication: return Icons.medication; - case ActiveAgent.report: return Icons.file_open; - case ActiveAgent.exercise: return Icons.directions_run; - case ActiveAgent.consultation: return Icons.chat; - default: return Icons.chat_bubble_outline; - } + return switch (agent) { + ActiveAgent.health => Icons.health_and_safety_outlined, + ActiveAgent.diet => Icons.restaurant_outlined, + ActiveAgent.medication => Icons.medication_outlined, + ActiveAgent.report => Icons.description_outlined, + ActiveAgent.exercise => Icons.directions_run_outlined, + ActiveAgent.consultation => Icons.chat_bubble_outline, + _ => Icons.chat_bubble_outline, + }; } String _formatTime(DateTime time) { @@ -266,3 +504,19 @@ class _ConversationItem extends ConsumerWidget { return '${time.month}/${time.day}'; } } + +(_ColorSet bg, _ColorSet accent) _conversationColors(ActiveAgent agent) { + return switch (agent) { + ActiveAgent.health => (const _ColorSet(0xFFE8F5E9), const _ColorSet(0xFF26C281)), + ActiveAgent.diet => (const _ColorSet(0xFFFFF3E0), const _ColorSet(0xFFFF8C42)), + ActiveAgent.medication => (const _ColorSet(0xFFFFEBEE), const _ColorSet(0xFFE898A8)), + ActiveAgent.report => (const _ColorSet(0xFFEDE7F6), const _ColorSet(0xFF8B6CF7)), + ActiveAgent.exercise => (const _ColorSet(0xFFE0F7FA), const _ColorSet(0xFF00BCD4)), + ActiveAgent.consultation => (const _ColorSet(0xFFE3F2FD), const _ColorSet(0xFF4D96FF)), + _ => (const _ColorSet(0xFFF5F5F5), const _ColorSet(0xFF999999)), + }; +} + +class _ColorSet extends Color { + const _ColorSet(int super.value); +}