refactor: 死代码清理 + 趋势图重构 + 侧边栏交互优化 + 智能体卡片去延迟

- 死代码清理: 删除 AppCard/AppMenuItem/AppTabChip/AppButtons 等 4 个未使用 Widget 文件; 清理 common_widgets/enterprise_widgets/app_status_badge 中未使用 class; 移除 HealthService 等未使用方法; 后端移除 ExerciseService.CreateFromItemsAsync/HealthArchiveService.GetOrCreateAsync/MedicationAgentHandler 死分支/ChatRequest DTO
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格; X 轴日+月份; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层; 选中点变实心
- 侧边栏: 对话记录改单选删除(灰底高亮); 操作栏浮层修复触摸问题; 常用功能/健康仪表盘间距收紧; _Panel 去外框
- 智能体: 欢迎卡片去掉 400ms 延迟; 胶囊去阴影
- 其他: api_client IP 适配; 多页面 UI 微调; 新增 chat_provider/prelaunch_guardrails 测试
This commit is contained in:
MingNian
2026-07-09 15:04:02 +08:00
parent 1c020b8ae5
commit d82e006cf4
37 changed files with 1842 additions and 2096 deletions

View File

@@ -4,12 +4,30 @@ import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
import '../../providers/data_providers.dart';
const String defaultTrendMetricType = 'blood_pressure';
const Set<String> supportedTrendMetricTypes = {
'blood_pressure',
'heart_rate',
'glucose',
'spo2',
'weight',
};
String normalizeTrendMetricType(String? metricType) {
if (metricType == null) return defaultTrendMetricType;
return supportedTrendMetricTypes.contains(metricType)
? metricType
: defaultTrendMetricType;
}
/// 健康概览趋势页 — 五大指标合到一个页面
class TrendPage extends ConsumerStatefulWidget {
final String? metricType; // 可选初始选中指标
@@ -20,7 +38,7 @@ class TrendPage extends ConsumerStatefulWidget {
}
class _TrendPageState extends ConsumerState<TrendPage> {
String _selected = 'blood_pressure';
String _selected = defaultTrendMetricType;
List<Map<String, dynamic>> _allRecords = [];
List<Map<String, dynamic>> _filtered = [];
bool _loading = true;
@@ -84,12 +102,15 @@ class _TrendPageState extends ConsumerState<TrendPage> {
String get _name => _names[_selected] ?? '';
bool get _isBP => _selected == 'blood_pressure';
Color get _color =>
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
_metrics.firstWhere(
(m) => m['key'] == normalizeTrendMetricType(_selected),
)['color']
as Color;
@override
void initState() {
super.initState();
if (widget.metricType != null) _selected = widget.metricType!;
_selected = normalizeTrendMetricType(widget.metricType);
_loadAll();
}
@@ -293,7 +314,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
title: const Text('健康概览'),
centerTitle: true,
),
@@ -431,8 +455,11 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}
// X 轴刻度间隔:日标签短,可以排密一点
final xInterval =
spots.length > 60 ? 5.0 : spots.length > 30 ? 2.0 : 1.0;
final xInterval = spots.length > 60
? 5.0
: spots.length > 30
? 2.0
: 1.0;
return Container(
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
@@ -490,10 +517,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
? (r['systolic'] as num?)?.toDouble()
: (r['value'] as num?)?.toDouble();
if (v != null && spots.length > 1) {
final px = 40.0 +
final px =
40.0 +
(_selectedIdx! / (spots.length - 1)) * paintWidth;
final py =
paintHeight * (1 - (v - minV) / (maxV - minV));
final py = paintHeight * (1 - (v - minV) / (maxV - minV));
final d = r['date'] as DateTime;
tooltipPos = Offset(px, py);
tooltipText1 =
@@ -513,191 +540,198 @@ class _TrendPageState extends ConsumerState<TrendPage> {
minY: minV,
maxY: maxV,
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: yStep,
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
v.toStringAsFixed(0),
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
interval: xInterval,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) {
return const SizedBox.shrink();
}
final d = _filtered[idx]['date'] as DateTime;
// 当前可见刻度与上一个可见刻度相比,月份是否变化
final prevIdx = idx - xInterval.toInt();
final showMonth = idx == 0 ||
prevIdx < 0 ||
(_filtered[prevIdx]['date'] as DateTime).month !=
d.month;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${d.day}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
if (showMonth) ...[
const SizedBox(height: 2),
Text(
'${d.month}',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: yStep,
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
v.toStringAsFixed(0),
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
),
],
],
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: _color,
barWidth: 2.5,
isStrokeCapRound: true,
dotData: FlDotData(
show: spots.length <= 30,
getDotPainter: (spot, _, _, _) {
final isSelected = spot.x.toInt() == _selectedIdx;
if (isSelected) {
return FlDotCirclePainter(
radius: 5.5,
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
interval: xInterval,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) {
return const SizedBox.shrink();
}
final d = _filtered[idx]['date'] as DateTime;
// 当前可见刻度与上一个可见刻度相比,月份是否变化
final prevIdx = idx - xInterval.toInt();
final showMonth =
idx == 0 ||
prevIdx < 0 ||
(_filtered[prevIdx]['date'] as DateTime)
.month !=
d.month;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${d.day}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
if (showMonth) ...[
const SizedBox(height: 2),
Text(
'${d.month}',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
],
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: _color,
strokeWidth: 2.5,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(
radius: 3.5,
color: Colors.white,
strokeWidth: 2,
strokeColor: _color,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_color.withValues(alpha: 0.22),
_color.withValues(alpha: 0.02),
barWidth: 2.5,
isStrokeCapRound: true,
dotData: FlDotData(
show: spots.length <= 30,
getDotPainter: (spot, _, _, _) {
final isSelected =
spot.x.toInt() == _selectedIdx;
if (isSelected) {
return FlDotCirclePainter(
radius: 5.5,
color: _color,
strokeWidth: 2.5,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(
radius: 3.5,
color: Colors.white,
strokeWidth: 2,
strokeColor: _color,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_color.withValues(alpha: 0.22),
_color.withValues(alpha: 0.02),
],
),
),
shadow: Shadow(
color: _color.withValues(alpha: 0.18),
blurRadius: 6,
offset: const Offset(0, 3),
),
),
],
),
),
shadow: Shadow(
color: _color.withValues(alpha: 0.18),
blurRadius: 6,
offset: const Offset(0, 3),
),
),
],
lineTouchData: LineTouchData(
handleBuiltInTouches: false,
touchCallback: (event, response) {
if (event is FlTapUpEvent) {
final spots = response?.lineBarSpots;
if (spots != null && spots.isNotEmpty) {
final idx = spots.first.x.toInt();
setState(() =>
_selectedIdx = (_selectedIdx == idx) ? null : idx);
} else {
setState(() => _selectedIdx = null);
}
}
},
),
),
duration: Duration.zero,
),
if (tooltipPos != null)
Positioned(
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
top: tooltipPos.dy - 56,
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _color.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: const Color(0xFF101828)
.withValues(alpha: 0.14),
blurRadius: 16,
offset: const Offset(0, 6),
lineTouchData: LineTouchData(
handleBuiltInTouches: false,
touchCallback: (event, response) {
if (event is FlTapUpEvent) {
final spots = response?.lineBarSpots;
if (spots != null && spots.isNotEmpty) {
final idx = spots.first.x.toInt();
setState(
() => _selectedIdx = (_selectedIdx == idx)
? null
: idx,
);
} else {
setState(() => _selectedIdx = null);
}
}
},
),
],
),
duration: Duration.zero,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tooltipText1!,
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
if (tooltipPos != null)
Positioned(
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
top: tooltipPos.dy - 56,
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _color.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: const Color(
0xFF101828,
).withValues(alpha: 0.14),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tooltipText1!,
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
tooltipText2!,
style: TextStyle(
fontSize: 15,
color: _color,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
const SizedBox(height: 2),
Text(
tooltipText2!,
style: TextStyle(
fontSize: 15,
color: _color,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
),
],
);
},
@@ -731,137 +765,161 @@ class _TrendPageState extends ConsumerState<TrendPage> {
],
),
const SizedBox(height: 10),
..._filtered.reversed.take(30).map((r) {
final date = r['date'] as DateTime;
final abnormal = r['isAbnormal'] == true;
final id = r['id']?.toString() ?? '';
final idx = _filtered.indexOf(r);
final isSelected = idx == _selectedIdx;
String display;
if (_isBP) {
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
} else {
display = '${r['value'] ?? '--'}';
}
return Dismissible(
key: Key(id),
direction: DismissDirection.endToStart,
background: Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
try {
final api = ref.read(apiClientProvider);
await api.delete('/api/health-records/$id');
setState(() {
_allRecords.removeWhere((x) => x['id']?.toString() == id);
_filtered.removeWhere((x) => x['id']?.toString() == id);
_selectedIdx = null;
});
return true;
} catch (_) {
return false;
}
},
child: GestureDetector(
onTap: () {
if (idx < 0) return;
setState(() =>
_selectedIdx = isSelected ? null : idx);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(bottom: 6),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? _color.withValues(alpha: 0.15)
: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? _color.withValues(alpha: 0.55)
: AppColors.border,
width: isSelected ? 1.5 : 1,
),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _color.withAlpha(20),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Icon(
_getMetricIcon(_selected),
size: 21,
color: _color,
),
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
),
child: Column(
children: [
..._filtered.reversed.take(30).toList().asMap().entries.map((
entry,
) {
final r = entry.value;
final isLast =
entry.key == _filtered.reversed.take(30).length - 1;
final date = r['date'] as DateTime;
final abnormal = r['isAbnormal'] == true;
final id = r['id']?.toString() ?? '';
final idx = _filtered.indexOf(r);
final isSelected = idx == _selectedIdx;
String display;
if (_isBP) {
display =
'${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
} else {
display = '${r['value'] ?? '--'}';
}
return Dismissible(
key: Key(id),
direction: DismissDirection.endToStart,
background: Container(
color: AppColors.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete_outline,
color: Colors.white,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
confirmDismiss: (_) async {
try {
final api = ref.read(apiClientProvider);
await api.delete('/api/health-records/$id');
setState(() {
_allRecords.removeWhere(
(x) => x['id']?.toString() == id,
);
_filtered.removeWhere((x) => x['id']?.toString() == id);
_selectedIdx = null;
});
return true;
} catch (_) {
return false;
}
},
child: GestureDetector(
onTap: () {
if (idx < 0) return;
setState(() => _selectedIdx = isSelected ? null : idx);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
color: isSelected
? _color.withValues(alpha: 0.10)
: Colors.white,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _color.withAlpha(20),
borderRadius: AppRadius.smBorder,
),
child: Center(
child: Icon(
_getMetricIcon(_selected),
size: 21,
color: _color,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
),
),
const SizedBox(height: 2),
Text(
'$display $_unit',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.error
: AppColors.textPrimary,
),
),
],
),
),
if (abnormal)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: AppRadius.xsBorder,
),
child: const Text(
'异常',
style: TextStyle(
fontSize: 14,
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
const SizedBox(height: 2),
Text(
'$display $_unit',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.error
: AppColors.textPrimary,
),
),
],
if (!isLast)
const Padding(
padding: EdgeInsets.only(left: 66),
child: Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
),
),
),
if (abnormal)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'异常',
style: TextStyle(
fontSize: 14,
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}),
);
}),
],
),
),
],
);
}