feat: 侧边栏重设计 - 彩色分区卡片+动画入场

This commit is contained in:
MingNian
2026-06-03 21:29:47 +08:00
parent f6c1ea7ec9
commit 5bd0155e17
8 changed files with 737 additions and 562 deletions

View File

@@ -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<HomePage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
bool _taskCardsExpanded = true;
String? _pickedImagePath;
double _lastScrollOffset = 0;
DateTime? _lastCollapseTime;
bool _exerciseDone = false;
final Set<ActiveAgent> _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<HomePage> {
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<String, dynamic> 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<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
final now = DateTime.now();
final tasks = <Widget>[];
// 1. 数据摘要卡片(有今日指标数据时显示)
final summaryParts = <String>[];
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<Widget> _buildAbnormalRows(Map<String, dynamic> healthData) {
final rows = <Widget>[];
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<String> 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<String, dynamic> 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<HomePage> {
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;

View File

@@ -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 建议 ──
@@ -966,18 +945,7 @@ class ChatMessagesView extends ConsumerWidget {
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,
),
),
// 文字内容
if (isUser)
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
else
@@ -991,6 +959,31 @@ class ChatMessagesView extends ConsumerWidget {
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)
Padding(
padding: const EdgeInsets.only(top: 10),
@@ -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<String, dynamic> 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<String, dynamic> healthData, AsyncValue<List<Map<String, dynamic>>> reminders) {
final now = DateTime.now();
final tasks = <Widget>[];
// 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<Color> 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;

View File

@@ -48,7 +48,7 @@ class MedicationListPage extends ConsumerWidget {
);
},
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
error: (_, __) => _empty(context),
error: (_, e) => _empty(context),
)),
_buildReminderBar(),
]),

View File

@@ -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()),
]),
);
}

View File

@@ -83,7 +83,7 @@ class ExercisePlanPage extends ConsumerWidget {
]);
},
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
error: (_, e) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
),
);
}
@@ -135,6 +135,7 @@ class ExercisePlanPage extends ConsumerWidget {
}
void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
try {
final service = ref.read(exerciseServiceProvider);
final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1));
@@ -151,9 +152,17 @@ class ExercisePlanPage extends ConsumerWidget {
ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('运动计划已创建'),
backgroundColor: Color(0xFF8B9CF7),
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 {

View File

@@ -171,17 +171,18 @@ class ChatNotifier extends Notifier<ChatState> {
// 上传失败:保留本地路径,仍然可以本地显示
}
// 更新消息元数据(上传成功则替换为远程 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 = <String, dynamic>{'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);
}

View File

@@ -96,7 +96,19 @@ final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((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,
}),
};
}
});

View File

@@ -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),
),
),
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)),
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.symmetric(horizontal: 12),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
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'})),
_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.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
error: (_, _) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
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: [
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: '--'),
_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 Divider(),
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.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),
padding: const EdgeInsets.only(bottom: 10),
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)))),
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))),
]),
),
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(),
]),
),
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')),
],
),
],
),
),
);
},
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))),
),
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' : '--';
if (metric is Map) { final v = metric['value']; return v?.toString() ?? '--'; }
return metric.toString();
}
return '--';
Widget _buildConversationList(WidgetRef ref, AsyncValue<List<ConversationItem>> 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<Color>? gradientColors;
const _SectionCard({required this.child, required this.color, this.gradientColors});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
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(
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 80,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
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(12),
border: Border.all(color: const Color(0xFFF0F2FF)),
),
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});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF8F9FC),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accentColor.withAlpha(30)),
),
child: ListTile(
leading: Container(
width: 28,
height: 28,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
color: accentColor.withAlpha(15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getAgentIcon(item.agent), size: 14, color: const Color(0xFF8B9CF7)),
child: Icon(icon, size: 15, color: accentColor),
),
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])),
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])),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// 功能按钮(横向)
// ═══════════════════════════════════════════════════════════════
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) {
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(vertical: 2),
decoration: BoxDecoration(
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: 32, height: 32,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getAgentIcon(item.agent), size: 15, color: colors.$2),
),
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<int>(
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<bool>(
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('确定')),
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]),
],
),
);
if (ok == true) {
ref.invalidate(conversationListProvider);
}
}
},
),
],
),
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);
}