feat: 侧边栏重设计 - 彩色分区卡片+动画入场
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,7 +48,7 @@ class MedicationListPage extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||
error: (_, __) => _empty(context),
|
||||
error: (_, e) => _empty(context),
|
||||
)),
|
||||
_buildReminderBar(),
|
||||
]),
|
||||
|
||||
@@ -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()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user