Files
AI-Health/health_app/lib/widgets/health_drawer.dart
MingNian 1c020b8ae5 feat: UI 系统中心化 + 趋势图重构 + 法律文档 H5 上线
- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心
- 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles
- 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
2026-07-08 21:25:07 +08:00

919 lines
27 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 'package:shadcn_ui/shadcn_ui.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../providers/conversation_history_provider.dart';
import 'app_toast.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(12, 16, 12, 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: LucideIcons.folderHeart,
title: '档案',
route: 'healthArchive',
colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
),
_NavItem(
icon: LucideIcons.fileText,
title: '报告',
route: 'reports',
colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)],
),
_NavItem(
icon: LucideIcons.pill,
title: '用药',
route: 'medications',
colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)],
),
_NavItem(
icon: LucideIcons.utensils,
title: '饮食',
route: 'dietRecords',
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
),
_NavItem(
icon: LucideIcons.calendarDays,
title: '日历',
route: 'calendar',
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
),
_NavItem(
icon: LucideIcons.calendarCheck,
title: '随访',
route: 'followups',
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
),
_NavItem(
icon: LucideIcons.footprints,
title: '运动',
route: 'exercisePlan',
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
),
_NavItem(
icon: LucideIcons.bluetooth,
title: '设备',
route: 'devices',
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
),
];
return _LightSection(
title: '常用功能',
child: GridView.builder(
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 48,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
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 [AppColors.healthLight, AppColors.health],
'reports' => const [AppColors.reportLight, AppColors.report],
'medications' => const [AppColors.medicationLight, AppColors.medication],
'dietRecords' => const [AppColors.dietLight, AppColors.diet],
'calendar' => const [AppColors.calendarLight, AppColors.calendar],
'followups' => const [AppColors.followupLight, AppColors.followup],
'exercisePlan' => const [AppColors.exerciseLight, AppColors.exercise],
'devices' => const [AppColors.deviceLight, AppColors.device],
_ => item.colors,
};
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: AppRadius.mdBorder,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.54),
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _colors,
),
borderRadius: AppRadius.smBorder,
),
child: Icon(item.icon, color: Colors.white, size: 19),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
],
),
),
);
}
}
class _LightSection extends StatelessWidget {
final String title;
final Widget child;
const _LightSection({required this.title, required this.child});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
),
],
),
),
const SizedBox(height: 10),
child,
],
);
}
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: AppRadius.xlBorder,
border: Border.all(
color: Colors.white.withValues(alpha: 0.82),
width: 1.2,
),
boxShadow: AppShadows.soft,
),
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,
});
}
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) {
AppToast.show(
context,
'清空失败,请稍后重试',
type: AppToastType.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}';
}
}