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 适配; 主页智能体栏间距优化
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -5,6 +7,7 @@ import '../../core/app_colors.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';
|
||||
|
||||
/// 健康概览趋势页 — 五大指标合到一个页面
|
||||
@@ -21,6 +24,37 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
List<Map<String, dynamic>> _allRecords = [];
|
||||
List<Map<String, dynamic>> _filtered = [];
|
||||
bool _loading = true;
|
||||
int? _selectedIdx; // 当前选中的数据点(_filtered 索引),null = 未选中
|
||||
|
||||
/// 计算"好看"的坐标轴步长,最小为 1(保证标签都是整数)。
|
||||
/// 目标 3 段 = 4 个标签;若吸附后超过 3 段,自动放大步长。
|
||||
double _niceStep(double range) {
|
||||
if (range <= 0) return 1;
|
||||
final target = range / 3;
|
||||
if (target < 1) return 1;
|
||||
final magnitude = pow(10, (log(target) / log(10)).floor()).toDouble();
|
||||
final normalized = target / magnitude;
|
||||
double nice;
|
||||
if (normalized <= 1) {
|
||||
nice = 1;
|
||||
} else if (normalized <= 2) {
|
||||
nice = 2;
|
||||
} else if (normalized <= 5) {
|
||||
nice = 5;
|
||||
} else {
|
||||
nice = 10;
|
||||
}
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
/// 步长过大时,跳到下一个"好看"的步长(1→2→5→10→20→50…)
|
||||
double _bumpStep(double step) {
|
||||
final magnitude = pow(10, (log(step) / log(10)).floor()).toDouble();
|
||||
final normalized = step / magnitude;
|
||||
if (normalized < 1.5) return 2 * magnitude;
|
||||
if (normalized < 4) return 5 * magnitude;
|
||||
return 10 * magnitude;
|
||||
}
|
||||
|
||||
static const _metrics = [
|
||||
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
|
||||
@@ -127,7 +161,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
}
|
||||
|
||||
void _switchMetric(String key) {
|
||||
setState(() => _selected = key);
|
||||
setState(() {
|
||||
_selected = key;
|
||||
_selectedIdx = null;
|
||||
});
|
||||
_filter();
|
||||
}
|
||||
|
||||
@@ -231,9 +268,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('录入失败')));
|
||||
AppToast.show(context, '录入失败', type: AppToastType.error);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
@@ -385,6 +420,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
maxV += padding;
|
||||
}
|
||||
|
||||
// Y 轴:吸附到"好看"的整数刻度,标签全部为整数,最多 4 个(3 段)
|
||||
var yStep = _niceStep(maxV - minV);
|
||||
minV = (minV / yStep).floor() * yStep;
|
||||
maxV = (maxV / yStep).ceil() * yStep;
|
||||
while ((maxV - minV) / yStep > 3) {
|
||||
yStep = _bumpStep(yStep);
|
||||
minV = (minV / yStep).floor() * yStep;
|
||||
maxV = (maxV / yStep).ceil() * yStep;
|
||||
}
|
||||
|
||||
// X 轴刻度间隔:日标签短,可以排密一点
|
||||
final xInterval =
|
||||
spots.length > 60 ? 5.0 : spots.length > 30 ? 2.0 : 1.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -424,34 +473,62 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: (spots.length - 1).toDouble(),
|
||||
minY: minV,
|
||||
maxY: maxV,
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: range > 50
|
||||
? 10
|
||||
: range > 20
|
||||
? 5
|
||||
: range > 5
|
||||
? 2
|
||||
: 1,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final chartWidth = constraints.maxWidth;
|
||||
final paintWidth = chartWidth - 40.0; // 减左侧轴
|
||||
final paintHeight = 200.0 - 36.0; // 减底部轴
|
||||
// 计算选中点的像素位置
|
||||
Offset? tooltipPos;
|
||||
String? tooltipText1;
|
||||
String? tooltipText2;
|
||||
if (_selectedIdx != null &&
|
||||
_selectedIdx! >= 0 &&
|
||||
_selectedIdx! < _filtered.length) {
|
||||
final r = _filtered[_selectedIdx!];
|
||||
final v = _isBP
|
||||
? (r['systolic'] as num?)?.toDouble()
|
||||
: (r['value'] as num?)?.toDouble();
|
||||
if (v != null && spots.length > 1) {
|
||||
final px = 40.0 +
|
||||
(_selectedIdx! / (spots.length - 1)) * paintWidth;
|
||||
final py =
|
||||
paintHeight * (1 - (v - minV) / (maxV - minV));
|
||||
final d = r['date'] as DateTime;
|
||||
tooltipPos = Offset(px, py);
|
||||
tooltipText1 =
|
||||
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
||||
tooltipText2 = _isBP
|
||||
? '${r['systolic']}/${r['diastolic']} $_unit'
|
||||
: '$v $_unit';
|
||||
}
|
||||
}
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: (spots.length - 1).toDouble(),
|
||||
minY: minV,
|
||||
maxY: maxV,
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (v, meta) => Text(
|
||||
v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -459,24 +536,43 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 24,
|
||||
interval: spots.length > 30
|
||||
? 7
|
||||
: spots.length > 14
|
||||
? 3
|
||||
: 1,
|
||||
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;
|
||||
return Text(
|
||||
'${d.month}/${d.day}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
// 当前可见刻度与上一个可见刻度相比,月份是否变化
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -491,46 +587,120 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
isCurved: false,
|
||||
color: _color,
|
||||
barWidth: 2.5,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 60,
|
||||
getDotPainter: (spot, _, _, _) => FlDotCirclePainter(
|
||||
radius: 3,
|
||||
color: _color,
|
||||
strokeWidth: 1,
|
||||
strokeColor: Colors.white,
|
||||
),
|
||||
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,
|
||||
color: _color.withAlpha(20),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots.map((s) {
|
||||
final idx = s.x.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) return null;
|
||||
final r = _filtered[idx];
|
||||
final d = r['date'] as DateTime;
|
||||
final v = _isBP
|
||||
? '${r['systolic']}/${r['diastolic']}'
|
||||
: '${r['value']}';
|
||||
return LineTooltipItem(
|
||||
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
||||
TextStyle(
|
||||
color: _color,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
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),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -547,7 +717,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'历史记录',
|
||||
'录入记录',
|
||||
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -565,6 +735,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
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'] ?? '--'}';
|
||||
@@ -591,21 +763,37 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
setState(() {
|
||||
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||
_selectedIdx = null;
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
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(
|
||||
@@ -671,6 +859,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user