Files
AI-Health/health_app/lib/widgets/health_drawer.dart
MingNian 7a93237069 feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
2026-07-06 12:44:59 +08:00

897 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../providers/conversation_history_provider.dart';
import '../providers/data_providers.dart';
import 'drawer_shell.dart';
class HealthDrawer extends ConsumerWidget {
const HealthDrawer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final latestHealth = ref.watch(latestHealthProvider);
return DrawerShell(
widthFactor: 0.9,
child: SafeArea(
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
sliver: SliverList.list(
children: [
_AccountHeader(user: auth.user, ref: ref),
const SizedBox(height: 18),
_HealthDashboard(latestHealth: latestHealth, ref: ref),
const SizedBox(height: 18),
_NavigationSection(ref: ref),
const SizedBox(height: 18),
_HistorySection(ref: ref),
],
),
),
],
),
),
);
}
}
class _AccountHeader extends StatelessWidget {
final dynamic user;
final WidgetRef ref;
const _AccountHeader({required this.user, required this.ref});
@override
Widget build(BuildContext context) {
final name = (user?.name?.toString().isNotEmpty ?? false)
? user.name.toString()
: '未设置昵称';
final phone = (user?.phone?.toString().isNotEmpty ?? false)
? user.phone.toString()
: '未登录';
return Row(
children: [
InkWell(
onTap: () => pushRoute(ref, 'profile'),
borderRadius: BorderRadius.circular(33),
child: Container(
width: 66,
height: 66,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: const Color(0xFF7C5CFF).withValues(alpha: 0.16),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.person_rounded,
color: Color(0xFF94A3B8),
size: 34,
),
),
),
const SizedBox(width: 14),
Expanded(
child: InkWell(
onTap: () => pushRoute(ref, 'profile'),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 5),
Text(
phone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
),
),
),
),
_IconButton(
icon: Icons.settings_rounded,
onTap: () => pushRoute(ref, 'settings'),
),
],
);
}
}
class _HealthDashboard extends StatelessWidget {
final AsyncValue<Map<String, dynamic>> latestHealth;
final WidgetRef ref;
const _HealthDashboard({required this.latestHealth, required this.ref});
@override
Widget build(BuildContext context) {
return _Panel(
title: '健康仪表盘',
trailing: _TextAction(
label: '详情',
light: true,
onTap: () => pushRoute(ref, 'trend'),
),
titleColor: Colors.white,
backgroundGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6), Color(0xFFF472B6)],
),
child: latestHealth.when(
data: (data) => _DashboardMetrics(data: data, ref: ref),
loading: () => const SizedBox(
height: 118,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (error, stackTrace) =>
_DashboardMetrics(data: const <String, dynamic>{}, ref: ref),
),
);
}
}
class _DashboardMetrics extends StatelessWidget {
final Map<String, dynamic> data;
final WidgetRef ref;
const _DashboardMetrics({required this.data, required this.ref});
@override
Widget build(BuildContext context) {
final items = [
_MetricInfo(
label: '血压',
value: _bpText(data['BloodPressure']),
unit: 'mmHg',
routeType: 'blood_pressure',
),
_MetricInfo(
label: '心率',
value: _metricVal(data['HeartRate']),
unit: 'bpm',
routeType: 'heart_rate',
),
_MetricInfo(
label: '血糖',
value: _metricVal(data['Glucose']),
unit: 'mmol/L',
routeType: 'glucose',
),
_MetricInfo(
label: '血氧',
value: _metricVal(data['SpO2']),
unit: '%',
routeType: 'spo2',
),
];
return Row(
children: [
for (var i = 0; i < items.length; i++) ...[
Expanded(
child: _MetricTile(
info: items[i],
onTap: () =>
pushRoute(ref, 'trend', params: {'type': items[i].routeType}),
),
),
if (i != items.length - 1) const SizedBox(width: 6),
],
],
);
}
static String _bpText(dynamic bp) {
if (bp is Map) {
final systolic = bp['systolic'] ?? bp['value'] ?? '--';
final diastolic = bp['diastolic'];
if (diastolic == null) return systolic.toString();
return '$systolic/$diastolic';
}
return '--';
}
static String _metricVal(dynamic val, {String unit = ''}) {
if (val == null) return '--';
dynamic raw = val;
if (val is Map) raw = val['value'] ?? val['val'] ?? val['data'];
if (raw == null) return '--';
final text = raw is num
? raw.toStringAsFixed(raw is double ? 1 : 0)
: raw.toString();
if (text.isEmpty || text == '--') return '--';
return unit.isEmpty ? text : '$text $unit';
}
}
class _MetricTile extends StatelessWidget {
final _MetricInfo info;
final VoidCallback onTap;
const _MetricTile({required this.info, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
height: 100,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.40)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 32,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
info.value,
maxLines: 1,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
),
),
if (info.unit != null)
Text(
info.unit!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xE0FFFFFF),
),
),
const SizedBox(height: 2),
Text(
info.label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
],
),
),
);
}
}
class _NavigationSection extends StatelessWidget {
final WidgetRef ref;
const _NavigationSection({required this.ref});
@override
Widget build(BuildContext context) {
final items = [
_NavItem(
icon: Icons.folder_shared_rounded,
title: '档案',
route: 'healthArchive',
colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
),
_NavItem(
icon: Icons.description_rounded,
title: '报告',
route: 'reports',
colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)],
),
_NavItem(
icon: Icons.medication_rounded,
title: '用药',
route: 'medications',
colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)],
),
_NavItem(
icon: Icons.restaurant_rounded,
title: '饮食',
route: 'dietRecords',
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
),
_NavItem(
icon: Icons.calendar_month_rounded,
title: '日历',
route: 'calendar',
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
),
_NavItem(
icon: Icons.event_available_rounded,
title: '随访',
route: 'followups',
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
),
_NavItem(
icon: Icons.directions_run_rounded,
title: '运动',
route: 'exercisePlan',
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
),
_NavItem(
icon: Icons.bluetooth_connected_rounded,
title: '蓝牙设备',
route: 'devices',
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
),
];
return _Panel(
title: '功能入口',
backgroundGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFAFFFFFF), Color(0xF5EFF6FF)],
),
child: GridView.builder(
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisExtent: 98,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
),
itemBuilder: (context, index) {
final item = items[index];
return _NavTile(item: item, onTap: () => pushRoute(ref, item.route));
},
),
);
}
}
class _NavTile extends StatelessWidget {
final _NavItem item;
final VoidCallback onTap;
const _NavTile({required this.item, required this.onTap});
String get _title => switch (item.route) {
'healthArchive' => '健康档案',
'reports' => '报告管理',
'medications' => '用药管理',
'dietRecords' => '饮食记录',
'calendar' => '健康日历',
'followups' => '复查随访',
'exercisePlan' => '运动计划',
'devices' => '蓝牙设备',
_ => item.title,
};
List<Color> get _colors => switch (item.route) {
'healthArchive' => const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
'reports' => const [Color(0xFF60A5FA), Color(0xFF2563EB)],
'medications' => const [Color(0xFFA78BFA), Color(0xFF7C3AED)],
'dietRecords' => const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
_ => item.colors,
};
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderLight, width: 1.1),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _colors,
),
borderRadius: BorderRadius.circular(16),
),
child: Icon(item.icon, color: Colors.white, size: 24),
),
const SizedBox(height: 9),
Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
],
),
),
);
}
}
class _Panel extends StatelessWidget {
final String title;
final Widget child;
final Widget? trailing;
final LinearGradient? backgroundGradient;
final Color titleColor;
const _Panel({
required this.title,
required this.child,
this.trailing,
this.backgroundGradient,
this.titleColor = AppColors.textPrimary,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
gradient:
backgroundGradient ??
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.92),
const Color(0xFFF8F4FF).withValues(alpha: 0.84),
],
),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Colors.white.withValues(alpha: 0.82),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF6D5DF6).withValues(alpha: 0.08),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: titleColor,
),
),
),
?trailing,
],
),
const SizedBox(height: 12),
child,
],
),
);
}
}
class _TextAction extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool light;
const _TextAction({
required this.label,
required this.onTap,
this.light = false,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
decoration: BoxDecoration(
color: light
? Colors.white.withValues(alpha: 0.24)
: const Color(0xFFEDE9FE),
borderRadius: BorderRadius.circular(999),
border: light
? Border.all(color: Colors.white.withValues(alpha: 0.34))
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w900,
color: light ? Colors.white : Color(0xFF6D28D9),
),
),
),
);
}
}
class _IconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _IconButton({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.84),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white, width: 1.2),
),
child: Icon(icon, size: 22, color: const Color(0xFF475569)),
),
);
}
}
class _MetricInfo {
final String label;
final String value;
final String? unit;
final String routeType;
const _MetricInfo({
required this.label,
required this.value,
this.unit,
required this.routeType,
});
}
class _NavItem {
final IconData icon;
final String title;
final String route;
final List<Color> colors;
const _NavItem({
required this.icon,
required this.title,
required this.route,
required this.colors,
});
}
/// 侧边栏底部:最近 5 条对话历史 + 查看全部 + 清空。
class _HistorySection extends ConsumerWidget {
final WidgetRef ref;
const _HistorySection({required this.ref});
static const int _previewCount = 5;
@override
Widget build(BuildContext context, WidgetRef _) {
final async = ref.watch(conversationHistoryProvider);
return _Panel(
title: '对话记录',
trailing: _HistoryActions(ref: ref, async: async),
backgroundGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFFFFF), Color(0xFFF6F1FF), Color(0xFFEFF6FF)],
),
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (error, stackTrace) => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text(
'加载失败',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'暂无历史,发起对话后会显示在这里',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
);
}
final preview = list.take(_previewCount).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < preview.length; i++)
_DrawerHistoryTile(
item: preview[i],
isLast: i == preview.length - 1,
onTap: () async {
Navigator.of(context).maybePop(); // 关侧边栏
await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
},
onDelete: () async {
try {
await ref
.read(conversationHistoryProvider.notifier)
.deleteOne(preview[i].id);
} catch (_) {
// 失败时 provider 已回滚状态UI 自然恢复
}
},
),
if (list.length > _previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
Navigator.of(context).maybePop();
pushRoute(ref, 'conversationHistory');
},
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(
'查看全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
),
),
),
],
],
);
},
),
);
}
}
class _HistoryActions extends StatelessWidget {
final WidgetRef ref;
final AsyncValue<List<ConversationListItem>> async;
const _HistoryActions({required this.ref, required this.async});
@override
Widget build(BuildContext context) {
final hasItems = (async.asData?.value.isNotEmpty ?? false);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(),
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.refresh_rounded,
size: 18,
color: Color(0xFF6D28D9),
),
),
),
if (hasItems)
InkWell(
onTap: () => _confirmClearAll(context, ref),
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.delete_sweep_rounded,
size: 18,
color: AppColors.error,
),
),
),
],
);
}
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
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),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('清空'),
),
],
),
);
if (ok != true) return;
try {
await ref.read(conversationHistoryProvider.notifier).clearAll();
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('清空失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
}
}
}
}
class _DrawerHistoryTile extends StatelessWidget {
final ConversationListItem item;
final bool isLast;
final VoidCallback onTap;
final VoidCallback onDelete;
const _DrawerHistoryTile({
required this.item,
required this.isLast,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey('drawer-${item.id}'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 14),
margin: EdgeInsets.only(bottom: isLast ? 0 : 8),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
onDelete();
return true;
},
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
margin: EdgeInsets.only(bottom: isLast ? 0 : 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
),
child: Row(
children: [
Expanded(
child: Text(
_displaySummary(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.2,
),
),
),
const SizedBox(width: 10),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
],
),
),
),
);
}
static String _displayTitle(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s;
final t = item.title?.trim();
if (t != null && t.isNotEmpty) return t;
return '未命名对话';
}
static String _displaySummary(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
return _displayTitle(item);
}
static String _shortDate(DateTime time) {
final now = DateTime.now();
final local = time.toLocal();
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return '今天';
}
final yesterday = now.subtract(const Duration(days: 1));
if (local.year == yesterday.year &&
local.month == yesterday.month &&
local.day == yesterday.day) {
return '昨天';
}
return '${local.month}/${local.day}';
}
}