- 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 适配; 主页智能体栏间距优化
886 lines
31 KiB
Dart
886 lines
31 KiB
Dart
import 'dart:math';
|
||
|
||
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_theme.dart';
|
||
import '../../core/navigation_provider.dart';
|
||
import '../../providers/auth_provider.dart';
|
||
import '../../widgets/app_toast.dart';
|
||
import '../../providers/data_providers.dart';
|
||
|
||
/// 健康概览趋势页 — 五大指标合到一个页面
|
||
class TrendPage extends ConsumerStatefulWidget {
|
||
final String? metricType; // 可选初始选中指标
|
||
const TrendPage({super.key, this.metricType});
|
||
|
||
@override
|
||
ConsumerState<TrendPage> createState() => _TrendPageState();
|
||
}
|
||
|
||
class _TrendPageState extends ConsumerState<TrendPage> {
|
||
String _selected = 'blood_pressure';
|
||
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)},
|
||
{'key': 'heart_rate', 'label': '心率', 'color': Color(0xFFF59E0B)},
|
||
{'key': 'glucose', 'label': '血糖', 'color': Color(0xFF4F6EF7)},
|
||
{'key': 'spo2', 'label': '血氧', 'color': Color(0xFF20C997)},
|
||
{'key': 'weight', 'label': '体重', 'color': Color(0xFF845EF7)},
|
||
];
|
||
|
||
static const _units = {
|
||
'blood_pressure': 'mmHg',
|
||
'heart_rate': 'bpm',
|
||
'glucose': 'mmol/L',
|
||
'spo2': '%',
|
||
'weight': 'kg',
|
||
};
|
||
|
||
static const _names = {
|
||
'blood_pressure': '血压',
|
||
'heart_rate': '心率',
|
||
'glucose': '血糖',
|
||
'spo2': '血氧',
|
||
'weight': '体重',
|
||
};
|
||
|
||
String get _unit => _units[_selected] ?? '';
|
||
String get _name => _names[_selected] ?? '';
|
||
bool get _isBP => _selected == 'blood_pressure';
|
||
Color get _color =>
|
||
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.metricType != null) _selected = widget.metricType!;
|
||
_loadAll();
|
||
}
|
||
|
||
Future<void> _loadAll() async {
|
||
setState(() => _loading = true);
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight'];
|
||
final all = <Map<String, dynamic>>[];
|
||
for (final t in types) {
|
||
try {
|
||
final res = await api.get(
|
||
'/api/health-records/trend',
|
||
queryParameters: {'type': t, 'period': 365},
|
||
);
|
||
final list =
|
||
(res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
for (final r in list) {
|
||
all.add({
|
||
'id': r['id'],
|
||
'type': t,
|
||
'date':
|
||
DateTime.tryParse(
|
||
r['recordedAt']?.toString() ?? '',
|
||
)?.toLocal() ??
|
||
DateTime.now(),
|
||
'systolic': r['systolic'],
|
||
'diastolic': r['diastolic'],
|
||
'value': r['value'],
|
||
'unit': r['unit'] ?? '',
|
||
'isAbnormal': r['isAbnormal'] == true,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[Trend] 加载趋势失败: $e');
|
||
}
|
||
}
|
||
all.sort(
|
||
(a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime),
|
||
);
|
||
if (mounted) {
|
||
setState(() {
|
||
_allRecords = all;
|
||
_loading = false;
|
||
});
|
||
_filter();
|
||
}
|
||
} catch (_) {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
void _filter() {
|
||
final typeName = _selected == 'blood_pressure'
|
||
? 'BloodPressure'
|
||
: _selected == 'heart_rate'
|
||
? 'HeartRate'
|
||
: _selected == 'glucose'
|
||
? 'Glucose'
|
||
: _selected == 'spo2'
|
||
? 'SpO2'
|
||
: 'Weight';
|
||
setState(() {
|
||
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
|
||
_filtered.sort(
|
||
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
|
||
);
|
||
});
|
||
}
|
||
|
||
void _switchMetric(String key) {
|
||
setState(() {
|
||
_selected = key;
|
||
_selectedIdx = null;
|
||
});
|
||
_filter();
|
||
}
|
||
|
||
// ---- 手动录入 ----
|
||
void _showAddDialog() {
|
||
final ctrl1 = TextEditingController();
|
||
final ctrl2 = TextEditingController();
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) => Padding(
|
||
padding: EdgeInsets.only(
|
||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||
left: 20,
|
||
right: 20,
|
||
top: 20,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'录入$_name',
|
||
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (_isBP) ...[
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: ctrl1,
|
||
keyboardType: TextInputType.number,
|
||
decoration: const InputDecoration(
|
||
labelText: '收缩压 (mmHg)',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: ctrl2,
|
||
keyboardType: TextInputType.number,
|
||
decoration: const InputDecoration(
|
||
labelText: '舒张压 (mmHg)',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
] else
|
||
TextField(
|
||
controller: ctrl1,
|
||
keyboardType: TextInputType.number,
|
||
decoration: InputDecoration(
|
||
labelText: '$_name ($_unit)',
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton(
|
||
onPressed: () async {
|
||
final v1 = double.tryParse(ctrl1.text.trim());
|
||
if (v1 == null) return;
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final body = <String, dynamic>{
|
||
'type': _selected == 'blood_pressure'
|
||
? 'BloodPressure'
|
||
: _selected == 'heart_rate'
|
||
? 'HeartRate'
|
||
: _selected == 'glucose'
|
||
? 'Glucose'
|
||
: _selected == 'spo2'
|
||
? 'SpO2'
|
||
: 'Weight',
|
||
'source': 'Manual',
|
||
'recordedAt': DateTime.now().toUtc().toIso8601String(),
|
||
'unit': _unit,
|
||
};
|
||
if (_isBP) {
|
||
body['systolic'] = v1.toInt();
|
||
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
|
||
} else {
|
||
body['value'] = v1;
|
||
}
|
||
await api.post('/api/health-records', data: body);
|
||
if (!ctx.mounted || !mounted) {
|
||
return;
|
||
}
|
||
Navigator.pop(ctx);
|
||
ref.invalidate(latestHealthProvider);
|
||
_loadAll();
|
||
} catch (_) {
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
AppToast.show(context, '录入失败', type: AppToastType.error);
|
||
}
|
||
},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _color,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
),
|
||
child: const Text('确认录入', style: TextStyle(fontSize: 19)),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
||
title: const Text('健康概览'),
|
||
centerTitle: true,
|
||
),
|
||
floatingActionButton: FloatingActionButton(
|
||
onPressed: _showAddDialog,
|
||
backgroundColor: _color,
|
||
child: const Icon(Icons.add),
|
||
),
|
||
body: _loading
|
||
? const Center(
|
||
child: CircularProgressIndicator(color: AppTheme.primary),
|
||
)
|
||
: RefreshIndicator(
|
||
onRefresh: _loadAll,
|
||
child: SingleChildScrollView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
children: [
|
||
_buildMetricTabs(),
|
||
const SizedBox(height: 16),
|
||
_buildChart(),
|
||
const SizedBox(height: 20),
|
||
_buildHistory(),
|
||
const SizedBox(height: 80),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- 指标选择标签 ----
|
||
Widget _buildMetricTabs() {
|
||
return SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: _metrics.map((m) {
|
||
final key = m['key'] as String;
|
||
final color = m['color'] as Color;
|
||
final sel = _selected == key;
|
||
return GestureDetector(
|
||
onTap: () => _switchMetric(key),
|
||
child: Container(
|
||
margin: const EdgeInsets.only(right: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: sel ? color : Colors.white,
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: sel ? color : AppColors.border),
|
||
),
|
||
child: Text(
|
||
m['label'] as String,
|
||
style: TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
color: sel ? Colors.white : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- 趋势图 ----
|
||
Widget _buildChart() {
|
||
if (_filtered.isEmpty) {
|
||
return Container(
|
||
height: 220,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: AppColors.cardShadowLight,
|
||
),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.show_chart,
|
||
size: 48,
|
||
color: AppColors.textHint.withValues(alpha: 0.45),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'暂无$_name数据',
|
||
style: const TextStyle(color: AppColors.textHint),
|
||
),
|
||
const SizedBox(height: 4),
|
||
const Text(
|
||
'点击右下角 + 手动录入',
|
||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final spots = <FlSpot>[];
|
||
double minV = double.infinity, maxV = double.negativeInfinity;
|
||
for (int i = 0; i < _filtered.length; i++) {
|
||
final r = _filtered[i];
|
||
final v = _isBP
|
||
? (r['systolic'] as num?)?.toDouble()
|
||
: (r['value'] as num?)?.toDouble();
|
||
if (v == null) continue;
|
||
if (v < minV) minV = v;
|
||
if (v > maxV) maxV = v;
|
||
spots.add(FlSpot(i.toDouble(), v));
|
||
}
|
||
|
||
if (spots.isEmpty) return const SizedBox.shrink();
|
||
final range = maxV - minV;
|
||
final padding = range * 0.15;
|
||
if (padding < 1) {
|
||
minV -= 5;
|
||
maxV += 5;
|
||
} else {
|
||
minV -= padding;
|
||
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(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: AppColors.cardShadowLight,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
_name,
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: _color,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'趋势 (${_filtered.length}条)',
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'单位: $_unit',
|
||
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
SizedBox(
|
||
height: 200,
|
||
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,
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
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,
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- 历史记录列表 ----
|
||
Widget _buildHistory() {
|
||
if (_filtered.isEmpty) return const SizedBox.shrink();
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Text(
|
||
'录入记录',
|
||
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'${_filtered.length}条',
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
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: BorderRadius.circular(6),
|
||
),
|
||
child: const Text(
|
||
'异常',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: AppColors.error,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
|
||
IconData _getMetricIcon(String key) {
|
||
switch (key) {
|
||
case 'blood_pressure':
|
||
return Icons.monitor_heart_outlined;
|
||
case 'heart_rate':
|
||
return Icons.favorite_border;
|
||
case 'glucose':
|
||
return Icons.water_drop_outlined;
|
||
case 'spo2':
|
||
return Icons.air_outlined;
|
||
case 'weight':
|
||
return Icons.monitor_weight_outlined;
|
||
default:
|
||
return Icons.show_chart;
|
||
}
|
||
}
|
||
}
|