release 24

This commit is contained in:
Developer
2026-04-24 05:05:26 +08:00
parent 5e979d7115
commit 7d5d95d5e0
8 changed files with 1485 additions and 764 deletions

View File

@@ -3,6 +3,73 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.99.40] - 2026-04-24
### ✨ 优化 — 身体分析页面5项需求实现 + 溢出修复
#### 修复: Tab页面底部溢出 1084px
- **根因**: 3个Tab使用 `Column` 布局,内容超出 `SizedBox` 固定高度(900-1200px)
- **修复**: Tab内部改用 `SingleChildScrollView` 支持滚动
- **新增**: 每个Tab底部增加 👈 左右滑动切换提示
#### 修复: Tab滑到底后无法滑回顶部
- **根因**: 外层 `ListView` 与内层 `SingleChildScrollView` 嵌套滚动冲突,向上滑动时外层抢占手势
- **修复**: 分析结果展示时改用 `Column` + `Expanded` 布局PageView独立占据剩余空间彻底消除嵌套滚动
- **优化**: 结果页面顶部改为紧凑信息栏(性别/年龄/身高/体重)+ 🔄重新分析按钮,节省空间
#### 新增: 趣味统计 - 各星球体重
- **功能**: 展示用户在9个天体水星/金星/地球/火星/木星/土星/天王星/海王星/月球)上的体重
- **数据**: 基于各行星相对地球的平均重力加速度计算
- **UI**: 使用 Wrap 布局的 Chip 样式展示,地球高亮标记
#### 新增: 有氧运动(燃脂)心率计算卡片
- **功能**: 在核心指标Tab中BMI卡片后新增 ❤️‍🔥 燃脂最佳心率卡片
- **展示**: 渐变背景大字显示最佳心率区间 xx-xx bpm标注为最大心率的60%~70%
#### 优化: 摄入需求/蛋白质根据BMI智能显示
- **逻辑**: 偏瘦(BMI<18.5)隐藏减脂选项,肥胖(BMI≥28)隐藏增肌选项
- **影响**: GoalCaloriesCard 和 ProteinCard 均已适配
#### 新增: Tab滑动边缘发光动画
- **效果**: 在第1个Tab继续左滑时左边缘显示主题色渐变发光最后1个Tab右滑同理
- **实现**: NotificationListener监听OverscrollNotificationStack+Positioned叠加边缘发光层
#### 需求1: 修复按钮状态不实时更新
- **问题**: 输入身高/年龄/体重后,"开始分析"按钮仍为禁用状态,需额外操作才能激活
- **根因**: `onChanged` 仅在 `_hasResult == true` 时调用 `setState()`,首次输入不触发重建
- **修复**: `onChanged` 始终调用 `setState()`,按钮状态实时响应输入
#### 需求2: AppBar 增加隐私与算法说明
- 在导航栏右侧增加 图标按钮
- 点击弹出 `CupertinoAlertDialog`,包含:
- 🔒 隐私声明:所有数据仅在本地计算,不会联网上传
- 📐 算法来源Harris-Benedict / Mifflin-St Jeor / US Navy / Karvonen 等
- ⚠️ 免责声明:仅供学习参考,禁止医用、禁止商用
#### 需求3: 参考文献页面增加身体分析数据来源
- 新增 7 条 🧬 身体分析分类参考文献:
- Harris-Benedict BMR 公式 (Wikipedia)
- Mifflin-St Jeor BMR 公式 (PubMed)
- US Navy 体脂率估算法 (US Navy)
- Karvonen 心率区间公式 (Wikipedia)
- WHO BMI 分类与肥胖标准 (WHO)
- 中国居民膳食营养素参考摄入量 2023版 (中国营养学会)
- Mosteller 体表面积计算公式 (Wikipedia)
#### 需求4: 基础信息卡片增加保存/隐藏按钮
- 💾 保存按钮:将填写信息写入 SharedPreferences下次打开自动填充
- 👁️ 隐藏/显示按钮:切换输入区域显示状态,数据保留仅隐藏表单
#### 需求5: 核心指标/科学指数/趣味统计支持左右滑动
- 将 Tab 切换改为 `PageView` 实现,支持手势左右滑动
- `GlassSegmentedControl``PageView` 双向联动
#### 涉及文件
- `body_analysis_page.dart` — 全部5项需求实现
- `references_page.dart` — 新增7条身体分析参考文献
---
## [0.99.39] - 2026-04-24 ## [0.99.39] - 2026-04-24
### 🐛 修复 — 排敏助手页面一直转圈 + 闪退 + Obx异常 ### 🐛 修复 — 排敏助手页面一直转圈 + 闪退 + Obx异常

View File

@@ -375,7 +375,7 @@ class ToolRegistry {
), ),
ToolItem( ToolItem(
id: 'anti_allergy', id: 'anti_allergy',
name: 'Anti 敏宝排敏助手', name: '敏宝排敏助手',
icon: '🛡️', icon: '🛡️',
needsNetwork: false, needsNetwork: false,
category: 'health', category: 'health',
@@ -395,11 +395,7 @@ class ToolRegistry {
category: 'health', category: 'health',
route: '/tools/body-analysis', route: '/tools/body-analysis',
description: '基于身高体重年龄分析25+项科学指标和趣味统计', description: '基于身高体重年龄分析25+项科学指标和趣味统计',
waterfallSlot: WaterfallSlotConfig( waterfallSlot: WaterfallSlotConfig(show: true, priority: 1, badge: 'NEW'),
show: true,
priority: 1,
badge: 'NEW',
),
), ),
]; ];
} }

View File

@@ -36,6 +36,15 @@ class WaterSchedule {
const WaterSchedule(this.time, this.ml, this.tip); const WaterSchedule(this.time, this.ml, this.tip);
} }
class PlanetWeight {
final String name;
final String emoji;
final double gravity;
final double weight;
const PlanetWeight(this.name, this.emoji, this.gravity, this.weight);
}
class BodyAnalysisCalculator { class BodyAnalysisCalculator {
final Gender gender; final Gender gender;
final double age; final double age;
@@ -423,6 +432,21 @@ class BodyAnalysisCalculator {
return stepsPerDay * strideLength * 365.25 * age; return stepsPerDay * strideLength * 365.25 * age;
} }
List<PlanetWeight> get planetWeights {
if (!isValid) return [];
return [
PlanetWeight('水星', '🪐', 0.38, weight * 0.38),
PlanetWeight('金星', '🌟', 0.91, weight * 0.91),
PlanetWeight('地球', '🌍', 1.00, weight),
PlanetWeight('火星', '🔴', 0.38, weight * 0.38),
PlanetWeight('木星', '🟤', 2.36, weight * 2.36),
PlanetWeight('土星', '🪐', 1.08, weight * 1.08),
PlanetWeight('天王星', '🔵', 0.92, weight * 0.92),
PlanetWeight('海王星', '🌊', 1.12, weight * 1.12),
PlanetWeight('月球', '🌙', 0.165, weight * 0.165),
];
}
double get fatLossCalories => tdee - 500; double get fatLossCalories => tdee - 500;
double get muscleGainCalories => tdee + 300; double get muscleGainCalories => tdee + 300;

View File

@@ -474,6 +474,133 @@ class HeartRateZonesCard extends StatelessWidget {
} }
} }
class FatBurningHeartRateCard extends StatelessWidget {
final bool isDark;
final BodyAnalysisCalculator calc;
const FatBurningHeartRateCard({
super.key,
required this.isDark,
required this.calc,
});
@override
Widget build(BuildContext context) {
final zones = calc.heartRateZones;
if (zones.isEmpty) return const SizedBox.shrink();
final fatBurnZone = zones[1];
return GlassContainer(
padding: const EdgeInsets.all(DesignTokens.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('❤️‍🔥', style: TextStyle(fontSize: 24)),
const SizedBox(width: DesignTokens.space2),
Expanded(
child: Text(
'有氧运动(燃脂)心率',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
),
InfoButton(
isDark: isDark,
title: '燃脂最佳心率',
content:
'最佳燃脂心率 = 最大心率 × 60%~70%\n'
'最大心率 = 220 - 年龄\n\n'
'在此心率区间运动时:\n'
'• 脂肪供能比例最高\n'
'• 可持续运动时间最长\n'
'• 适合快走、慢跑、骑行等有氧运动\n\n'
'建议每次运动30-60分钟\n'
'每周3-5次效果最佳。',
),
],
),
const SizedBox(height: DesignTokens.space3),
Container(
width: double.infinity,
padding: const EdgeInsets.all(DesignTokens.space4),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFF6B6B), Color(0xFFEE5A24)],
),
borderRadius: BorderRadius.all(
Radius.circular(DesignTokens.radiusLg),
),
),
child: Column(
children: [
Text(
'最佳心率区间',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: CupertinoColors.white.withValues(alpha: 0.9),
),
),
const SizedBox(height: DesignTokens.space1),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'${fatBurnZone.low.round()}',
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w800,
color: CupertinoColors.white,
),
),
Text(
' - ',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
color: CupertinoColors.white.withValues(alpha: 0.8),
),
),
Text(
'${fatBurnZone.high.round()}',
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w800,
color: CupertinoColors.white,
),
),
const SizedBox(width: DesignTokens.space2),
Text(
'bpm',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: CupertinoColors.white.withValues(alpha: 0.8),
),
),
],
),
const SizedBox(height: DesignTokens.space1),
Text(
'最大心率 ${calc.maxHeartRate} bpm 的 60%~70%',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: CupertinoColors.white.withValues(alpha: 0.7),
),
),
],
),
),
],
),
);
}
}
class GoalCaloriesCard extends StatelessWidget { class GoalCaloriesCard extends StatelessWidget {
final bool isDark; final bool isDark;
final BodyAnalysisCalculator calc; final BodyAnalysisCalculator calc;
@@ -482,6 +609,10 @@ class GoalCaloriesCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isOverweight = calc.bmi >= 28;
final isUnderweight = calc.bmi < 18.5;
final showFatLoss = !isUnderweight;
final showMuscleGain = !isOverweight;
return GlassContainer( return GlassContainer(
padding: const EdgeInsets.all(DesignTokens.space4), padding: const EdgeInsets.all(DesignTokens.space4),
child: Column( child: Column(
@@ -505,29 +636,36 @@ class GoalCaloriesCard extends StatelessWidget {
isDark: isDark, isDark: isDark,
title: '摄入需求', title: '摄入需求',
content: content:
'基于TDEE计算不同目标的每日摄入量\n\n' '基于TDEE和BMI计算不同目标的每日摄入量:\n\n'
'减脂: TDEE - 500 kcal每周约减0.5kg\n' '减脂: TDEE - 500 kcal每周约减0.5kg\n'
'增肌: TDEE + 300 kcal配合力量训练\n' '增肌: TDEE + 300 kcal配合力量训练\n'
'维持: TDEE保持当前体重\n\n' '维持: TDEE保持当前体重\n\n'
'根据BMI智能推荐\n'
'偏瘦(BMI<18.5): 隐藏减脂选项\n'
'肥胖(BMI≥28): 隐藏增肌选项\n\n'
'建议不要低于BMR否则影响基础代谢。', '建议不要低于BMR否则影响基础代谢。',
), ),
], ],
), ),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildGoalRow( if (showFatLoss) ...[
'🔥 减脂', _buildGoalRow(
calc.fatLossCalories, '🔥 减脂',
DesignTokens.orange, calc.fatLossCalories,
'kcal/天', DesignTokens.orange,
), 'kcal/天',
const SizedBox(height: DesignTokens.space2), ),
_buildGoalRow( if (showMuscleGain) const SizedBox(height: DesignTokens.space2),
'💪 增肌', ],
calc.muscleGainCalories, if (showMuscleGain) ...[
DesignTokens.green, _buildGoalRow(
'kcal/天', '💪 增肌',
), calc.muscleGainCalories,
const SizedBox(height: DesignTokens.space2), DesignTokens.green,
'kcal/天',
),
const SizedBox(height: DesignTokens.space2),
],
_buildGoalRow( _buildGoalRow(
'⚖️ 维持', '⚖️ 维持',
calc.maintenanceCalories, calc.maintenanceCalories,
@@ -579,6 +717,10 @@ class ProteinCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isOverweight = calc.bmi >= 28;
final isUnderweight = calc.bmi < 18.5;
final showFatLoss = !isUnderweight;
final showMuscleGain = !isOverweight;
return GlassContainer( return GlassContainer(
padding: const EdgeInsets.all(DesignTokens.space4), padding: const EdgeInsets.all(DesignTokens.space4),
child: Column( child: Column(
@@ -611,10 +753,18 @@ class ProteinCard extends StatelessWidget {
], ],
), ),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildGoalRow('🔥 减脂', calc.proteinForFatLoss, DesignTokens.orange), if (showFatLoss) ...[
const SizedBox(height: DesignTokens.space2), _buildGoalRow('🔥 减脂', calc.proteinForFatLoss, DesignTokens.orange),
_buildGoalRow('💪 增肌', calc.proteinForMuscleGain, DesignTokens.green), if (showMuscleGain) const SizedBox(height: DesignTokens.space2),
const SizedBox(height: DesignTokens.space2), ],
if (showMuscleGain) ...[
_buildGoalRow(
'💪 增肌',
calc.proteinForMuscleGain,
DesignTokens.green,
),
const SizedBox(height: DesignTokens.space2),
],
_buildGoalRow('⚖️ 维持', calc.proteinForMaintenance, DesignTokens.blue), _buildGoalRow('⚖️ 维持', calc.proteinForMaintenance, DesignTokens.blue),
], ],
), ),
@@ -847,3 +997,104 @@ class IdealWeightCard extends StatelessWidget {
); );
} }
} }
class PlanetWeightCard extends StatelessWidget {
final bool isDark;
final BodyAnalysisCalculator calc;
const PlanetWeightCard({super.key, required this.isDark, required this.calc});
@override
Widget build(BuildContext context) {
final planets = calc.planetWeights;
if (planets.isEmpty) return const SizedBox.shrink();
return GlassContainer(
padding: const EdgeInsets.all(DesignTokens.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('🚀', style: TextStyle(fontSize: 24)),
const SizedBox(width: DesignTokens.space2),
Expanded(
child: Text(
'各星球上的体重',
style: TextStyle(
fontSize: DesignTokens.fontLg,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
),
InfoButton(
isDark: isDark,
title: '各星球体重',
content:
'体重 = 质量 × 重力加速度\n\n'
'你的质量在宇宙中是不变的,\n'
'但不同星球的重力加速度不同,\n'
'所以你在不同星球上"称"出的重量也不同。\n\n'
'各行星以地球为基准的平均重力加速度:\n'
'水星 0.38 | 金星 0.91 | 地球 1.00\n'
'火星 0.38 | 木星 2.36 | 土星 1.08\n'
'天王星 0.92 | 海王星 1.12\n'
'月球 0.165\n\n'
'在木星上你最重,在月球上你最轻!',
),
],
),
const SizedBox(height: DesignTokens.space3),
Wrap(
spacing: DesignTokens.space2,
runSpacing: DesignTokens.space2,
children: planets.map((p) => _buildPlanetChip(p)).toList(),
),
],
),
);
}
Widget _buildPlanetChip(PlanetWeight planet) {
final isEarth = planet.gravity == 1.0;
final color = isEarth ? DesignTokens.dynamicPrimary : DesignTokens.purple;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3,
vertical: DesignTokens.space2,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: DesignTokens.borderRadiusMd,
border: isEarth
? Border.all(color: color.withValues(alpha: 0.3))
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(planet.emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: DesignTokens.space1),
Text(
planet.name,
style: TextStyle(
fontSize: DesignTokens.fontSm,
fontWeight: FontWeight.w500,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
const SizedBox(width: DesignTokens.space1),
Text(
'${planet.weight.toStringAsFixed(1)}kg',
style: TextStyle(
fontSize: DesignTokens.fontSm,
fontWeight: FontWeight.w700,
color: color,
),
),
],
),
);
}
}

View File

@@ -47,6 +47,27 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
late final PageController _pageController; late final PageController _pageController;
double _leftEdgeGlow = 0;
double _rightEdgeGlow = 0;
void _onOverscroll(OverscrollNotification notification) {
if (notification.overscroll < 0) {
setState(
() => _leftEdgeGlow = (-notification.overscroll / 40).clamp(0.0, 1.0),
);
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) setState(() => _leftEdgeGlow = 0);
});
} else {
setState(
() => _rightEdgeGlow = (notification.overscroll / 40).clamp(0.0, 1.0),
);
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) setState(() => _rightEdgeGlow = 0);
});
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -69,17 +90,17 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
} }
BodyAnalysisCalculator get _calc => BodyAnalysisCalculator( BodyAnalysisCalculator get _calc => BodyAnalysisCalculator(
gender: _gender, gender: _gender,
age: double.tryParse(_ageController.text) ?? 0, age: double.tryParse(_ageController.text) ?? 0,
height: double.tryParse(_heightController.text) ?? 0, height: double.tryParse(_heightController.text) ?? 0,
weight: double.tryParse(_weightController.text) ?? 0, weight: double.tryParse(_weightController.text) ?? 0,
waist: double.tryParse(_waistController.text), waist: double.tryParse(_waistController.text),
hip: double.tryParse(_hipController.text), hip: double.tryParse(_hipController.text),
neck: double.tryParse(_neckController.text), neck: double.tryParse(_neckController.text),
wrist: double.tryParse(_wristController.text), wrist: double.tryParse(_wristController.text),
activityLevel: _activityLevel, activityLevel: _activityLevel,
bodyFatInput: double.tryParse(_bodyFatController.text), bodyFatInput: double.tryParse(_bodyFatController.text),
); );
bool get _isValid => _calc.isValid; bool get _isValid => _calc.isValid;
@@ -161,14 +182,17 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildInfoSection('🔒 隐私声明', _buildInfoSection('🔒 隐私声明', '所有数据仅在您的设备本地计算,不会联网上传,不收集任何个人信息。'),
'所有数据仅在您的设备本地计算,不会联网上传,不收集任何个人信息。'),
SizedBox(height: 10), SizedBox(height: 10),
_buildInfoSection('📐 算法来源', _buildInfoSection(
'• BMR: Harris-Benedict / Mifflin-St Jeor 公式\n• BMI: WHO 国际标准\n• 体脂率: US Navy Method / BMI估算法\n• TDEE: 活动系数法\n• 心率区间: Karvonen 公式\n• 喝水量: 基于体重与活动量估算'), '📐 算法来源',
'• BMR: Harris-Benedict / Mifflin-St Jeor 公式\n• BMI: WHO 国际标准\n• 体脂率: US Navy Method / BMI估算法\n• TDEE: 活动系数法\n• 心率区间: Karvonen 公式\n• 喝水量: 基于体重与活动量估算',
),
SizedBox(height: 10), SizedBox(height: 10),
_buildInfoSection('⚠️ 免责声明', _buildInfoSection(
'本工具仅供学习参考,不作为医疗诊断依据。如有健康问题请咨询专业医生。禁止用于商业用途。'), '⚠️ 免责声明',
'本工具仅供学习参考,不作为医疗诊断依据。如有健康问题请咨询专业医生。禁止用于商业用途。',
),
], ],
), ),
), ),
@@ -187,8 +211,10 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, Text(
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), title,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
SizedBox(height: 4), SizedBox(height: 4),
Text(content, style: TextStyle(fontSize: 13, height: 1.4)), Text(content, style: TextStyle(fontSize: 13, height: 1.4)),
], ],
@@ -200,8 +226,9 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return CupertinoPageScaffold( return CupertinoPageScaffold(
backgroundColor: backgroundColor: isDark
isDark ? DarkDesignTokens.background : DesignTokens.background, ? DarkDesignTokens.background
: DesignTokens.background,
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
middle: const Text('🧬 身体分析'), middle: const Text('🧬 身体分析'),
trailing: Row( trailing: Row(
@@ -223,69 +250,251 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
border: null, border: null,
), ),
child: SafeArea( child: SafeArea(
child: ListView( child: _hasResult && _isValid
padding: const EdgeInsets.all(DesignTokens.space4), ? _buildResultLayout(isDark)
children: [ : _buildInputLayout(isDark),
_buildRequiredInputs(isDark), ),
const SizedBox(height: DesignTokens.space3), );
_buildOptionalToggle(isDark), }
if (_showOptional) ...[
const SizedBox(height: DesignTokens.space3), Widget _buildInputLayout(bool isDark) {
_buildOptionalInputs(isDark), return ListView(
], padding: const EdgeInsets.all(DesignTokens.space4),
const SizedBox(height: DesignTokens.space4), children: [
SizedBox( _buildRequiredInputs(isDark),
width: double.infinity, const SizedBox(height: DesignTokens.space3),
child: CupertinoButton.filled( _buildOptionalToggle(isDark),
borderRadius: DesignTokens.borderRadiusLg, if (_showOptional) ...[
onPressed: _isValid ? _analyze : null, const SizedBox(height: DesignTokens.space3),
child: const Text('🧬 开始分析'), _buildOptionalInputs(isDark),
), ],
), const SizedBox(height: DesignTokens.space4),
if (_hasResult && _isValid) ...[ SizedBox(
const SizedBox(height: DesignTokens.space5), width: double.infinity,
GlassSegmentedControl( child: CupertinoButton.filled(
segments: const [ borderRadius: DesignTokens.borderRadiusLg,
GlassSegment(label: '🏥 核心指标'), onPressed: _isValid ? _analyze : null,
GlassSegment(label: '🔬 科学指数'), child: const Text('🧬 开始分析'),
GlassSegment(label: '🎮 趣味统计'), ),
],
selectedIndex: _selectedTab,
onChanged: (i) {
setState(() => _selectedTab = i);
_pageController.animateToPage(
i,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
),
const SizedBox(height: DesignTokens.space4),
_buildSwipeableTabs(isDark),
],
],
), ),
],
);
}
Widget _buildResultLayout(bool isDark) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
DesignTokens.space4,
DesignTokens.space3,
DesignTokens.space4,
0,
),
child: Row(
children: [
Expanded(child: _buildCompactInfoBar(isDark)),
const SizedBox(width: DesignTokens.space2),
SizedBox(
child: CupertinoButton.filled(
borderRadius: DesignTokens.borderRadiusMd,
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3,
vertical: DesignTokens.space1,
),
minimumSize: Size.zero,
onPressed: () => setState(() {
_hasResult = false;
_isInputHidden = false;
}),
child: const Text('🔄 重新分析', style: TextStyle(fontSize: 13)),
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
child: GlassSegmentedControl(
segments: const [
GlassSegment(label: '🏥 核心指标'),
GlassSegment(label: '🔬 科学指数'),
GlassSegment(label: '🎮 趣味统计'),
],
selectedIndex: _selectedTab,
onChanged: (i) {
setState(() => _selectedTab = i);
_pageController.animateToPage(
i,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
),
),
Expanded(child: _buildSwipeableTabs(isDark)),
],
);
}
Widget _buildCompactInfoBar(bool isDark) {
final calc = _calc;
final genderText = _gender == Gender.male ? '👨 男' : '👩 女';
final ageText = _isInputHidden ? '***岁' : '${calc.age.toStringAsFixed(0)}';
final heightText = _isInputHidden
? '***cm'
: '${calc.height.toStringAsFixed(0)}cm';
final weightText = _isInputHidden
? '***kg'
: '${calc.weight.toStringAsFixed(0)}kg';
return Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3,
vertical: DesignTokens.space2,
),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusMd,
border: Border.all(
color: isDark
? DarkDesignTokens.glassBorder
: DesignTokens.glassBorder,
),
),
child: Row(
children: [
Text(
genderText,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
const SizedBox(width: DesignTokens.space2),
Text(
ageText,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(width: DesignTokens.space2),
Text(
heightText,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(width: DesignTokens.space2),
Text(
weightText,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const Spacer(),
GestureDetector(
onTap: _saveData,
child: Icon(
CupertinoIcons.floppy_disk,
size: 16,
color: DesignTokens.dynamicPrimary,
),
),
const SizedBox(width: DesignTokens.space2),
GestureDetector(
onTap: () => setState(() => _isInputHidden = !_isInputHidden),
child: Icon(
_isInputHidden ? CupertinoIcons.eye_slash : CupertinoIcons.eye,
size: 16,
color: DesignTokens.dynamicPrimary,
),
),
],
), ),
); );
} }
Widget _buildSwipeableTabs(bool isDark) { Widget _buildSwipeableTabs(bool isDark) {
final calc = _calc; final calc = _calc;
return SizedBox( final glowColor = DesignTokens.dynamicPrimary;
height: _selectedTab == 0 return Stack(
? 1200 children: [
: _selectedTab == 1 NotificationListener<OverscrollNotification>(
? 1000 onNotification: (notification) {
: 900, _onOverscroll(notification);
child: PageView( return false;
controller: _pageController, },
onPageChanged: (i) => setState(() => _selectedTab = i), child: PageView(
children: [ controller: _pageController,
CoreMetricsTab(isDark: isDark, calc: calc), onPageChanged: (i) => setState(() => _selectedTab = i),
ScienceMetricsTab(isDark: isDark, calc: calc), children: [
FunStatsTab(isDark: isDark, calc: calc), CoreMetricsTab(isDark: isDark, calc: calc),
], ScienceMetricsTab(isDark: isDark, calc: calc),
), FunStatsTab(isDark: isDark, calc: calc),
],
),
),
if (_leftEdgeGlow > 0)
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 80,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _leftEdgeGlow,
duration: const Duration(milliseconds: 300),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
glowColor.withValues(alpha: 0.85),
glowColor.withValues(alpha: 0.4),
glowColor.withValues(alpha: 0.0),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
),
),
if (_rightEdgeGlow > 0)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 80,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _rightEdgeGlow,
duration: const Duration(milliseconds: 300),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [
glowColor.withValues(alpha: 0.75),
glowColor.withValues(alpha: 0.3),
glowColor.withValues(alpha: 0.0),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
),
),
],
); );
} }
@@ -311,7 +520,9 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
onTap: _saveData, onTap: _saveData,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space2, vertical: DesignTokens.space1), horizontal: DesignTokens.space2,
vertical: DesignTokens.space1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusSm, borderRadius: DesignTokens.borderRadiusSm,
@@ -319,14 +530,20 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(CupertinoIcons.floppy_disk, Icon(
size: 14, color: DesignTokens.dynamicPrimary), CupertinoIcons.floppy_disk,
size: 14,
color: DesignTokens.dynamicPrimary,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('保存', Text(
style: TextStyle( '保存',
fontSize: DesignTokens.fontXs, style: TextStyle(
fontWeight: FontWeight.w500, fontSize: DesignTokens.fontXs,
color: DesignTokens.dynamicPrimary)), fontWeight: FontWeight.w500,
color: DesignTokens.dynamicPrimary,
),
),
], ],
), ),
), ),
@@ -336,7 +553,9 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
onTap: () => setState(() => _isInputHidden = !_isInputHidden), onTap: () => setState(() => _isInputHidden = !_isInputHidden),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space2, vertical: DesignTokens.space1), horizontal: DesignTokens.space2,
vertical: DesignTokens.space1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1),
borderRadius: DesignTokens.borderRadiusSm, borderRadius: DesignTokens.borderRadiusSm,
@@ -345,17 +564,21 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
_isInputHidden _isInputHidden
? CupertinoIcons.eye_slash ? CupertinoIcons.eye_slash
: CupertinoIcons.eye, : CupertinoIcons.eye,
size: 14, size: 14,
color: DesignTokens.dynamicPrimary), color: DesignTokens.dynamicPrimary,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(_isInputHidden ? '显示' : '隐藏', Text(
style: TextStyle( _isInputHidden ? '显示' : '隐藏',
fontSize: DesignTokens.fontXs, style: TextStyle(
fontWeight: FontWeight.w500, fontSize: DesignTokens.fontXs,
color: DesignTokens.dynamicPrimary)), fontWeight: FontWeight.w500,
color: DesignTokens.dynamicPrimary,
),
),
], ],
), ),
), ),
@@ -374,16 +597,20 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
), ),
child: Row( child: Row(
children: [ children: [
Icon(CupertinoIcons.eye_slash, Icon(
size: 16, CupertinoIcons.eye_slash,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3), size: 16,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
const SizedBox(width: DesignTokens.space2), const SizedBox(width: DesignTokens.space2),
Text( Text(
'信息已隐藏,点击"显示"查看', '信息已隐藏,点击"显示"查看',
style: TextStyle( style: TextStyle(
fontSize: DesignTokens.fontSm, fontSize: DesignTokens.fontSm,
color: color: isDark
isDark ? DarkDesignTokens.text3 : DesignTokens.text3), ? DarkDesignTokens.text3
: DesignTokens.text3,
),
), ),
], ],
), ),
@@ -393,25 +620,28 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
_buildGenderSelector(isDark), _buildGenderSelector(isDark),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _ageController, controller: _ageController,
label: '年龄', label: '年龄',
unit: '', unit: '',
placeholder: '请输入年龄'), placeholder: '请输入年龄',
),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _heightController, controller: _heightController,
label: '身高', label: '身高',
unit: 'cm', unit: 'cm',
placeholder: '请输入身高'), placeholder: '请输入身高',
),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _weightController, controller: _weightController,
label: '体重', label: '体重',
unit: 'kg', unit: 'kg',
placeholder: '请输入体重'), placeholder: '请输入体重',
),
], ],
], ],
), ),
@@ -422,10 +652,13 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('性别', Text(
style: TextStyle( '性别',
fontSize: DesignTokens.fontSm, style: TextStyle(
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2)), fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(height: DesignTokens.space2), const SizedBox(height: DesignTokens.space2),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -435,25 +668,29 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
children: { children: {
Gender.male: Padding( Gender.male: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4, horizontal: DesignTokens.space4,
vertical: DesignTokens.space2), vertical: DesignTokens.space2,
child: Text('👨 男性', ),
style: TextStyle( child: Text(
fontSize: DesignTokens.fontMd, '👨 男性',
color: isDark style: TextStyle(
? DarkDesignTokens.text1 fontSize: DesignTokens.fontMd,
: DesignTokens.text1)), color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
), ),
Gender.female: Padding( Gender.female: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4, horizontal: DesignTokens.space4,
vertical: DesignTokens.space2), vertical: DesignTokens.space2,
child: Text('👩 女性', ),
style: TextStyle( child: Text(
fontSize: DesignTokens.fontMd, '👩 女性',
color: isDark style: TextStyle(
? DarkDesignTokens.text1 fontSize: DesignTokens.fontMd,
: DesignTokens.text1)), color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
), ),
}, },
), ),
@@ -467,29 +704,36 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
onTap: () => setState(() => _showOptional = !_showOptional), onTap: () => setState(() => _showOptional = !_showOptional),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4, vertical: DesignTokens.space3), horizontal: DesignTokens.space4,
vertical: DesignTokens.space3,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card, color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg, borderRadius: DesignTokens.borderRadiusLg,
border: Border.all( border: Border.all(
color: isDark ? DarkDesignTokens.glassBorder : DesignTokens.glassBorder), color: isDark
? DarkDesignTokens.glassBorder
: DesignTokens.glassBorder,
),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
_showOptional _showOptional
? CupertinoIcons.chevron_up ? CupertinoIcons.chevron_up
: CupertinoIcons.chevron_down, : CupertinoIcons.chevron_down,
size: 16, size: 16,
color: DesignTokens.dynamicPrimary), color: DesignTokens.dynamicPrimary,
),
const SizedBox(width: DesignTokens.space2), const SizedBox(width: DesignTokens.space2),
Text( Text(
'更多参数(选填,可解锁更多指标)', '更多参数(选填,可解锁更多指标)',
style: TextStyle( style: TextStyle(
fontSize: DesignTokens.fontMd, fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: DesignTokens.dynamicPrimary), color: DesignTokens.dynamicPrimary,
),
), ),
], ],
), ),
@@ -503,53 +747,64 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('选填参数', Text(
style: TextStyle( '选填参数',
fontSize: DesignTokens.fontLg, style: TextStyle(
fontWeight: FontWeight.w600, fontSize: DesignTokens.fontLg,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1)), fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
const SizedBox(height: DesignTokens.space1), const SizedBox(height: DesignTokens.space1),
Text('填写越多,分析越精确', Text(
style: TextStyle( '填写越多,分析越精确',
fontSize: DesignTokens.fontSm, style: TextStyle(
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2)), fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _waistController, controller: _waistController,
label: '腰围', label: '腰围',
unit: 'cm', unit: 'cm',
placeholder: '选填'), placeholder: '选填',
),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _hipController, controller: _hipController,
label: '臀围', label: '臀围',
unit: 'cm', unit: 'cm',
placeholder: '选填'), placeholder: '选填',
),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _neckController, controller: _neckController,
label: '颈围', label: '颈围',
unit: 'cm', unit: 'cm',
placeholder: '选填'), placeholder: '选填',
),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _wristController, controller: _wristController,
label: '手腕围', label: '手腕围',
unit: 'cm', unit: 'cm',
placeholder: '选填'), placeholder: '选填',
),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildActivitySelector(isDark), _buildActivitySelector(isDark),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
_buildInputField( _buildInputField(
isDark: isDark, isDark: isDark,
controller: _bodyFatController, controller: _bodyFatController,
label: '已知体脂率', label: '已知体脂率',
unit: '%', unit: '%',
placeholder: '选填,跳过自动估算'), placeholder: '选填,跳过自动估算',
),
], ],
), ),
); );
@@ -567,10 +822,13 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('活动等级', Text(
style: TextStyle( '活动等级',
fontSize: DesignTokens.fontSm, style: TextStyle(
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2)), fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(height: DesignTokens.space2), const SizedBox(height: DesignTokens.space2),
Container( Container(
padding: const EdgeInsets.all(DesignTokens.space1), padding: const EdgeInsets.all(DesignTokens.space1),
@@ -587,8 +845,9 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
onTap: () => setState(() => _activityLevel = e.$1), onTap: () => setState(() => _activityLevel = e.$1),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3, horizontal: DesignTokens.space3,
vertical: DesignTokens.space2 + 2), vertical: DesignTokens.space2 + 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? (isDark ? DarkDesignTokens.card : DesignTokens.card) ? (isDark ? DarkDesignTokens.card : DesignTokens.card)
@@ -598,23 +857,30 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
), ),
child: Row( child: Row(
children: [ children: [
Text(e.$2, Text(
style: TextStyle( e.$2,
fontSize: DesignTokens.fontMd, style: TextStyle(
fontWeight: fontSize: DesignTokens.fontMd,
isSelected ? FontWeight.w600 : FontWeight.w400, fontWeight: isSelected
color: isSelected ? FontWeight.w600
? DesignTokens.dynamicPrimary : FontWeight.w400,
: (isDark color: isSelected
? DarkDesignTokens.text2 ? DesignTokens.dynamicPrimary
: DesignTokens.text2))), : (isDark
? DarkDesignTokens.text2
: DesignTokens.text2),
),
),
const SizedBox(width: DesignTokens.space2), const SizedBox(width: DesignTokens.space2),
Text(e.$3, Text(
style: TextStyle( e.$3,
fontSize: DesignTokens.fontSm, style: TextStyle(
color: isDark fontSize: DesignTokens.fontSm,
? DarkDesignTokens.text3 color: isDark
: DesignTokens.text3)), ? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
], ],
), ),
), ),
@@ -636,10 +902,13 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, Text(
style: TextStyle( label,
fontSize: DesignTokens.fontSm, style: TextStyle(
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2)), fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
const SizedBox(height: DesignTokens.space1), const SizedBox(height: DesignTokens.space1),
Row( Row(
children: [ children: [
@@ -647,7 +916,9 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
child: CupertinoTextField( child: CupertinoTextField(
controller: controller, controller: controller,
placeholder: placeholder, placeholder: placeholder,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
padding: const EdgeInsets.all(DesignTokens.space3), padding: const EdgeInsets.all(DesignTokens.space3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark color: isDark
@@ -661,18 +932,22 @@ class _BodyAnalysisPageState extends State<BodyAnalysisPage>
const SizedBox(width: DesignTokens.space2), const SizedBox(width: DesignTokens.space2),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space3, vertical: DesignTokens.space2), horizontal: DesignTokens.space3,
vertical: DesignTokens.space2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark color: isDark
? DarkDesignTokens.text3.withValues(alpha: 0.1) ? DarkDesignTokens.text3.withValues(alpha: 0.1)
: DesignTokens.background, : DesignTokens.background,
borderRadius: DesignTokens.borderRadiusMd, borderRadius: DesignTokens.borderRadiusMd,
), ),
child: Text(unit, child: Text(
style: TextStyle( unit,
fontSize: DesignTokens.fontMd, style: TextStyle(
color: fontSize: DesignTokens.fontMd,
isDark ? DarkDesignTokens.text2 : DesignTokens.text2)), color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
),
),
), ),
], ],
), ),

View File

@@ -3,7 +3,7 @@
* 名称: 身体分析核心指标Tab * 名称: 身体分析核心指标Tab
* 作用: 展示喝水/BMR/TDEE/BMI/体脂率/心率区间/摄入需求/蛋白质等核心指标 * 作用: 展示喝水/BMR/TDEE/BMI/体脂率/心率区间/摄入需求/蛋白质等核心指标
* 创建: 2026-04-24 * 创建: 2026-04-24
* 更新: 2026-04-24 从 body_analysis_page.dart 拆分 * 更新: 2026-04-24 修复溢出+增加滑动提示
*/ */
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -19,63 +19,103 @@ class CoreMetricsTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return SingleChildScrollView(
children: [ physics: const ClampingScrollPhysics(),
WaterCard(isDark: isDark, calc: calc), child: Column(
const SizedBox(height: DesignTokens.space3), children: [
MetricCard( WaterCard(isDark: isDark, calc: calc),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🔥', MetricCard(
title: '基础代谢率 (BMR)', isDark: isDark,
value: calc.bmr.toStringAsFixed(0), emoji: '🔥',
unit: 'kcal/天', title: '基础代谢率 (BMR)',
color: DesignTokens.orange, value: calc.bmr.toStringAsFixed(0),
infoTitle: '基础代谢率 (BMR)', unit: 'kcal/天',
infoContent: '基础代谢率是人体在安静状态下维持生命所需的最低能量消耗。' color: DesignTokens.orange,
'采用Mifflin-St Jeor公式计算被认为是目前最准确的BMR估算公式。\n\n' infoTitle: '基础代谢率 (BMR)',
'男性: BMR = 10×体重(kg) + 6.25×身高(cm) - 5×年龄 + 5\n' infoContent:
'女性: BMR = 10×体重(kg) + 6.25×身高(cm) - 5×年龄 - 161', '基础代谢率是人体在安静状态下维持生命所需的最低能量消耗。'
), '采用Mifflin-St Jeor公式计算被认为是目前最准确的BMR估算公式。\n\n'
const SizedBox(height: DesignTokens.space3), '男性: BMR = 10×体重(kg) + 6.25×身高(cm) - 5×年龄 + 5\n'
MetricCard( '女性: BMR = 10×体重(kg) + 6.25×身高(cm) - 5×年龄 - 161',
isDark: isDark, ),
emoji: '🏃', const SizedBox(height: DesignTokens.space3),
title: '每日总能量消耗 (TDEE)', MetricCard(
value: calc.tdee.toStringAsFixed(0), isDark: isDark,
unit: 'kcal/天', emoji: '🏃',
color: DesignTokens.green, title: '每日总能量消耗 (TDEE)',
infoTitle: '每日总能量消耗 (TDEE)', value: calc.tdee.toStringAsFixed(0),
infoContent: 'TDEE = BMR × 活动系数,表示你每天实际消耗的总热量。\n\n' unit: 'kcal/天',
'活动系数:\n' color: DesignTokens.green,
'久坐: 1.2 | 轻度: 1.375 | 中度: 1.55\n' infoTitle: '每日总能量消耗 (TDEE)',
'高度: 1.725 | 极高: 1.9\n\n' infoContent:
'减脂建议: TDEE - 500 kcal\n' 'TDEE = BMR × 活动系数,表示你每天实际消耗的总热量。\n\n'
'增肌建议: TDEE + 300 kcal', '活动系数:\n'
), '久坐: 1.2 | 轻度: 1.375 | 中度: 1.55\n'
const SizedBox(height: DesignTokens.space3), '高度: 1.725 | 极高: 1.9\n\n'
MetricCard( '减脂建议: TDEE - 500 kcal\n'
isDark: isDark, '增肌建议: TDEE + 300 kcal',
emoji: '📊', ),
title: '身体质量指数 (BMI)', const SizedBox(height: DesignTokens.space3),
value: calc.bmi.toStringAsFixed(1), MetricCard(
unit: calc.bmiCategory, isDark: isDark,
color: Color(calc.bmiColorValue), emoji: '📊',
infoTitle: '身体质量指数 (BMI)', title: '身体质量指数 (BMI)',
infoContent: 'BMI = 体重(kg) / 身高(m)²\n\n' value: calc.bmi.toStringAsFixed(1),
'分类标准(中国):\n' unit: calc.bmiCategory,
'< 18.5 偏瘦 | 18.5-23.9 正常\n' color: Color(calc.bmiColorValue),
'24-27.9 偏胖 | ≥ 28 肥胖\n\n' infoTitle: '身体质量指数 (BMI)',
'注意BMI未考虑肌肉量和体脂分布运动员等人群可能不准确。', infoContent:
), 'BMI = 体重(kg) / 身高(m)²\n\n'
const SizedBox(height: DesignTokens.space3), '分类标准(中国):\n'
BodyFatCard(isDark: isDark, calc: calc), '< 18.5 偏瘦 | 18.5-23.9 正常\n'
const SizedBox(height: DesignTokens.space3), '24-27.9 偏胖 | ≥ 28 肥胖\n\n'
HeartRateZonesCard(isDark: isDark, calc: calc), '注意BMI未考虑肌肉量和体脂分布运动员等人群可能不准确。',
const SizedBox(height: DesignTokens.space3), ),
GoalCaloriesCard(isDark: isDark, calc: calc), const SizedBox(height: DesignTokens.space3),
const SizedBox(height: DesignTokens.space3), FatBurningHeartRateCard(isDark: isDark, calc: calc),
ProteinCard(isDark: isDark, calc: calc), const SizedBox(height: DesignTokens.space3),
], BodyFatCard(isDark: isDark, calc: calc),
const SizedBox(height: DesignTokens.space3),
HeartRateZonesCard(isDark: isDark, calc: calc),
const SizedBox(height: DesignTokens.space3),
GoalCaloriesCard(isDark: isDark, calc: calc),
const SizedBox(height: DesignTokens.space3),
ProteinCard(isDark: isDark, calc: calc),
const SizedBox(height: DesignTokens.space5),
_buildSwipeHint(isDark),
],
),
);
}
Widget _buildSwipeHint(bool isDark) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
CupertinoIcons.chevron_left,
size: 14,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
const SizedBox(width: DesignTokens.space2),
Text(
'👈 左右滑动切换 核心指标 / 科学指数 / 趣味统计 👉',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
const SizedBox(width: DesignTokens.space2),
Icon(
CupertinoIcons.chevron_right,
size: 14,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
); );
} }
} }

View File

@@ -3,7 +3,7 @@
* 名称: 身体分析趣味统计Tab * 名称: 身体分析趣味统计Tab
* 作用: 展示心跳/呼吸/眨眼/DNA/原子等趣味统计数据 * 作用: 展示心跳/呼吸/眨眼/DNA/原子等趣味统计数据
* 创建: 2026-04-24 * 创建: 2026-04-24
* 更新: 2026-04-24 从 body_analysis_page.dart 拆分 * 更新: 2026-04-24 修复溢出+增加滑动提示
*/ */
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -19,264 +19,301 @@ class FunStatsTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return SingleChildScrollView(
children: [ physics: const ClampingScrollPhysics(),
MetricCard( child: Column(
isDark: isDark, children: [
emoji: '🫀', MetricCard(
title: '今日心跳次数', isDark: isDark,
value: calc.formatNumber(calc.dailyHeartbeats.toDouble()), emoji: '🫀',
unit: '', title: '今日心跳次数',
color: DesignTokens.red, value: calc.formatNumber(calc.dailyHeartbeats.toDouble()),
infoTitle: '心跳次数', unit: '',
infoContent: color: DesignTokens.red,
'平均心率因年龄而异:\n' infoTitle: '心跳次数',
'婴儿约130次/分 | 儿童约90次/分\n' infoContent:
'成人约72次/分\n\n' '平均心率因年龄而异:\n'
'心脏每天跳动约10万次一生约25-30亿次。', '婴儿约130次/分 | 儿童约90次/分\n'
), '成人约72次/分\n\n'
const SizedBox(height: DesignTokens.space3), '心脏每天跳动约10万次一生约25-30亿次。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🫁', MetricCard(
title: '今日呼吸次数', isDark: isDark,
value: calc.formatNumber(calc.dailyBreaths.toDouble()), emoji: '🫁',
unit: '', title: '今日呼吸次数',
color: DesignTokens.blue, value: calc.formatNumber(calc.dailyBreaths.toDouble()),
infoTitle: '呼吸次数', unit: '',
infoContent: color: DesignTokens.blue,
'成人平均呼吸频率约16次/分钟。\n\n' infoTitle: '呼吸次数',
'每次呼吸约吸入0.5升空气,\n' infoContent:
'每天呼吸约23,040次\n' '成人平均呼吸频率约16次/分钟。\n\n'
'吸入空气约11,520升。', '每次呼吸约吸入0.5升空气,\n'
), '每天呼吸约23,040次\n'
const SizedBox(height: DesignTokens.space3), '吸入空气约11,520升。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '👁️', MetricCard(
title: '今日眨眼次数', isDark: isDark,
value: calc.formatNumber(calc.dailyBlinks.toDouble()), emoji: '👁️',
unit: '', title: '今日眨眼次数',
color: DesignTokens.teal, value: calc.formatNumber(calc.dailyBlinks.toDouble()),
infoTitle: '眨眼次数', unit: '',
infoContent: color: DesignTokens.teal,
'人类平均每分钟眨眼15-20次\n' infoTitle: '眨眼次数',
'每天约14,400次。\n\n' infoContent:
'每次眨眼约0.1-0.4秒\n' '人类平均每分钟眨眼15-20次\n'
'眨眼可以清洁和润滑眼球,' '每天约14,400次。\n\n'
'同时大脑会利用眨眼间隙重新调整注意力。', '每次眨眼约0.1-0.4秒,\n'
), '眨眼可以清洁和润滑眼球,'
const SizedBox(height: DesignTokens.space3), '同时大脑会利用眨眼间隙重新调整注意力。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🩸', MetricCard(
title: '今日心脏泵血量', isDark: isDark,
value: calc.dailyBloodPumped.toStringAsFixed(0), emoji: '🩸',
unit: '', title: '今日心脏泵血量',
color: DesignTokens.red, value: calc.dailyBloodPumped.toStringAsFixed(0),
infoTitle: '心脏泵血量', unit: '',
infoContent: color: DesignTokens.red,
'心脏每次跳动泵出约70ml血液\n' infoTitle: '心脏泵血量',
'每天泵血约7,000-10,000升。\n\n' infoContent:
'这个量足以填满一个标准游泳池的1/250。', '心脏每次跳动泵出约70ml血液\n'
), '每天泵血约7,000-10,000升。\n\n'
const SizedBox(height: DesignTokens.space3), '这个量足以填满一个标准游泳池的1/250。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🌬️', MetricCard(
title: '今日呼吸空气体积', isDark: isDark,
value: calc.dailyAirVolume.toStringAsFixed(0), emoji: '🌬️',
unit: '', title: '今日呼吸空气体积',
color: DesignTokens.blue, value: calc.dailyAirVolume.toStringAsFixed(0),
infoTitle: '呼吸空气体积', unit: '',
infoContent: color: DesignTokens.blue,
'每次呼吸约0.5升每分钟16次\n' infoTitle: '呼吸空气体积',
'每天约吸入11,520升空气。\n\n' infoContent:
'这相当于约12立方米' '每次呼吸约0.5升每分钟16次\n'
'足够充满一个小型储藏室。', '每天约吸入11,520升空气。\n\n'
), '这相当于约12立方米'
const SizedBox(height: DesignTokens.space3), '足够充满一个小型储藏室。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '💧', MetricCard(
title: '今日唾液分泌量', isDark: isDark,
value: calc.dailySaliva.toStringAsFixed(1), emoji: '💧',
unit: '', title: '今日唾液分泌量',
color: DesignTokens.green, value: calc.dailySaliva.toStringAsFixed(1),
infoTitle: '唾液分泌量', unit: '',
infoContent: color: DesignTokens.green,
'人体每天分泌约1-1.5升唾液。\n\n' infoTitle: '唾液分泌量',
'唾液含有淀粉酶帮助消化,' infoContent:
'溶菌酶帮助杀菌,' '人体每天分泌约1-1.5升唾液。\n\n'
'还能保持口腔湿润、帮助味觉感知。\n' '唾液含有淀粉酶帮助消化,'
'一生约分泌25,000-40,000升唾液。', '溶菌酶帮助杀菌,'
), '还能保持口腔湿润、帮助味觉感知。\n'
const SizedBox(height: DesignTokens.space3), '一生约分泌25,000-40,000升唾液。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🧬', MetricCard(
title: '体内DNA总长度', isDark: isDark,
value: calc.formatDistance(calc.totalDnaLength), emoji: '🧬',
unit: '', title: '体内DNA总长度',
color: DesignTokens.purple, value: calc.formatDistance(calc.totalDnaLength),
infoTitle: 'DNA总长度', unit: '',
infoContent: color: DesignTokens.purple,
'人体约有37.2万亿个细胞,\n' infoTitle: 'DNA总长度',
'每个细胞含约2米长的DNA。\n\n' infoContent:
'全部DNA展开总长度约744亿公里\n' '人体约有37.2万亿个细胞\n'
'约为地球到太阳距离的500倍\n' '每个细胞含约2米长的DNA。\n\n'
'DNA直径仅2纳米比头发丝细10万倍。', '全部DNA展开总长度约744亿公里\n'
), '约为地球到太阳距离的500倍\n'
const SizedBox(height: DesignTokens.space3), '但DNA直径仅2纳米比头发丝细10万倍。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🌍', MetricCard(
title: '身体原子数量', isDark: isDark,
value: '~7×10²⁷', emoji: '🌍',
unit: '个原子', title: '身体原子数量',
color: DesignTokens.gold, value: '~7×10²⁷',
infoTitle: '身体原子数量', unit: '个原子',
infoContent: color: DesignTokens.gold,
'成年人体内约有7×10²⁷个原子\n' infoTitle: '身体原子数量',
'即7后面跟27个零。\n\n' infoContent:
'组成比例:\n' '成年人体内约有7×10²⁷个原子\n'
'氧65% | 碳18% | 氢10% | 氮3%\n' '即7后面跟27个零。\n\n'
'其余为钙、磷、钾、硫等微量元素。\n' '组成比例:\n'
'每年约98%的原子会被替换。', '氧65% | 碳18% | 氢10% | 氮3%\n'
), '其余为钙、磷、钾、硫等微量元素。\n'
const SizedBox(height: DesignTokens.space3), '每年约98%的原子会被替换。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '💇', MetricCard(
title: '一生头发生长总长度', isDark: isDark,
value: calc.formatDistance(calc.lifetimeHairLength), emoji: '💇',
unit: '', title: '一生头发生长总长度',
color: DesignTokens.orange, value: calc.formatDistance(calc.lifetimeHairLength),
infoTitle: '头发生长', unit: '',
infoContent: color: DesignTokens.orange,
'头发平均每月生长约1.25厘米,\n' infoTitle: '头发生长',
'每年约15厘米。\n\n' infoContent:
'人类头皮约有10万个毛囊\n' '头发平均每月生长约1.25厘米\n'
'天正常脱落50-100根头发。\n' '年约15厘米。\n\n'
'金发人群平均有15万个毛囊,\n' '人类头皮约有10万个毛囊,\n'
'红发人群约9万个。', '每天正常脱落50-100根头发。\n'
), '金发人群平均有15万个毛囊\n'
const SizedBox(height: DesignTokens.space3), '红发人群约9万个。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '💅', MetricCard(
title: '一生指甲生长总长度', isDark: isDark,
value: calc.formatDistance(calc.lifetimeNailLength), emoji: '💅',
unit: '', title: '一生指甲生长总长度',
color: DesignTokens.teal, value: calc.formatDistance(calc.lifetimeNailLength),
infoTitle: '指甲生长', unit: '',
infoContent: color: DesignTokens.teal,
'指甲每月生长约3.5毫米,\n' infoTitle: '指甲生长',
'脚趾甲生长速度约手指甲的1/3。\n\n' infoContent:
'指甲由角蛋白组成,与头发成分相同。\n' '指甲每月生长约3.5毫米,\n'
'惯用手的指甲生长更快,\n' '脚趾甲生长速度约手指甲的1/3。\n\n'
'夏天比冬天生长更快。', '指甲由角蛋白组成,与头发成分相同。\n'
), '惯用手的指甲生长更快,\n'
const SizedBox(height: DesignTokens.space3), '夏天比冬天生长更快。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '⏱️', MetricCard(
title: '一生心跳总数', isDark: isDark,
value: calc.formatNumber(calc.lifetimeHeartbeats), emoji: '⏱️',
unit: '次(基于年龄)', title: '一生心跳总数',
color: DesignTokens.red, value: calc.formatNumber(calc.lifetimeHeartbeats),
infoTitle: '一生心跳总数', unit: '次(基于年龄)',
infoContent: color: DesignTokens.red,
'基于你的年龄和平均心率估算。\n\n' infoTitle: '一生心跳总数',
'如果活到80岁心脏大约跳动30亿次。\n' infoContent:
'心脏是人体中最勤劳的器官,\n' '基于你的年龄和平均心率估算。\n\n'
'从胚胎4周开始跳动直到生命终结。', '如果活到80岁心脏大约跳动30亿次。\n'
), '心脏是人体中最勤劳的器官,\n'
const SizedBox(height: DesignTokens.space3), '从胚胎4周开始跳动直到生命终结。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🫁', MetricCard(
title: '一生呼吸总次数', isDark: isDark,
value: calc.formatNumber(calc.lifetimeBreaths), emoji: '🫁',
unit: '次(基于年龄)', title: '一生呼吸总次数',
color: DesignTokens.blue, value: calc.formatNumber(calc.lifetimeBreaths),
infoTitle: '一生呼吸总次数', unit: '次(基于年龄)',
infoContent: color: DesignTokens.blue,
'基于你的年龄和平均呼吸频率估算。\n\n' infoTitle: '一生呼吸总次数',
'成人每分钟约呼吸16次\n' infoContent:
'每小时960次每天约23,040次。\n' '基于你的年龄和平均呼吸频率估算。\n\n'
'如果活到80岁大约呼吸6-7亿次。', '成人每分钟约呼吸16次\n'
), '每小时960次每天约23,040次。\n'
const SizedBox(height: DesignTokens.space3), '如果活到80岁大约呼吸6-7亿次。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '👁️', MetricCard(
title: '一生眨眼总次数', isDark: isDark,
value: calc.formatNumber(calc.lifetimeBlinks), emoji: '👁️',
unit: '次(基于年龄)', title: '一生眨眼总次数',
color: DesignTokens.teal, value: calc.formatNumber(calc.lifetimeBlinks),
infoTitle: '一生眨眼总次数', unit: '次(基于年龄)',
infoContent: color: DesignTokens.teal,
'基于你的年龄和平均眨眼频率估算。\n\n' infoTitle: '一生眨眼总次数',
'每天约14,400次眨眼\n' infoContent:
'一生约4亿次眨眼\n\n' '基于你的年龄和平均眨眼频率估算\n\n'
'有趣的是每次眨眼约0.1-0.4秒\n' '每天约14,400次眨眼\n'
'一生中约有5天的时间在眨眼中度过。', '一生约4亿次眨眼。\n\n'
), '有趣的是每次眨眼约0.1-0.4秒,\n'
const SizedBox(height: DesignTokens.space3), '一生中约有5天的时间在眨眼中度过。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🧴', MetricCard(
title: '一生皮肤细胞脱落量', isDark: isDark,
value: calc.formatNumber(calc.lifetimeSkinCells), emoji: '🧴',
unit: '个(基于年龄)', title: '一生皮肤细胞脱落量',
color: DesignTokens.purple, value: calc.formatNumber(calc.lifetimeSkinCells),
infoTitle: '皮肤细胞脱落', unit: '个(基于年龄)',
infoContent: color: DesignTokens.purple,
'人体每天约脱落3-5千万个皮肤细胞\n' infoTitle: '皮肤细胞脱落',
'取中间值约4千万个/天。\n\n' infoContent:
'皮肤是人体最大的器官\n' '人体每天约脱落3-5千万个皮肤细胞\n'
'每27天左右皮肤就会完全更新一次。\n' '取中间值约4千万个/天。\n\n'
'一生脱落的皮肤细胞总重量约18-23公斤\n' '皮肤是人体最大的器官\n'
'相当于一个小孩的体重!', '每27天左右皮肤就会完全更新一次。\n'
), '一生脱落的皮肤细胞总重量约18-23公斤\n'
const SizedBox(height: DesignTokens.space3), '相当于一个小孩的体重!',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🍽️', MetricCard(
title: '一生消耗食物总重量', isDark: isDark,
value: calc.formatNumber(calc.lifetimeFoodWeight), emoji: '🍽️',
unit: '吨(基于年龄)', title: '一生消耗食物总重量',
color: DesignTokens.orange, value: calc.formatNumber(calc.lifetimeFoodWeight),
infoTitle: '一生食物消耗', unit: '吨(基于年龄)',
infoContent: color: DesignTokens.orange,
'基于你的TDEE和年龄估算。\n\n' infoTitle: '一生食物消耗',
'假设食物平均热量密度约2 kcal/g\n' infoContent:
'一个成年人一生大约吃掉25-30吨食物\n' '基于你的TDEE和年龄估算。\n\n'
'相当于约5头大象的重量\n\n' '假设食物平均热量密度约2 kcal/g\n'
'其中约60%为碳水化合物和脂肪\n' '一个成年人一生大约吃掉25-30吨食物\n'
'15%为蛋白质25%为水分和其他。', '相当于约5头大象的重量\n\n'
), '其中约60%为碳水化合物和脂肪,\n'
const SizedBox(height: DesignTokens.space3), '15%为蛋白质25%为水分和其他。',
MetricCard( ),
isDark: isDark, const SizedBox(height: DesignTokens.space3),
emoji: '🚶', MetricCard(
title: '一生行走距离估算', isDark: isDark,
value: calc.formatDistance(calc.lifetimeWalkingDistance * 100), emoji: '🚶',
unit: '', title: '一生行走距离估算',
color: DesignTokens.green, value: calc.formatDistance(calc.lifetimeWalkingDistance * 100),
infoTitle: '一生行走距离', unit: '',
infoContent: color: DesignTokens.green,
'基于你的年龄、身高和活动水平估算。\n\n' infoTitle: '一生行走距离',
'步幅 ≈ 身高 × 0.415\n' infoContent:
'每日步数根据年龄调整:\n' '基于你的年龄、身高和活动水平估算。\n\n'
'<10岁 12000步 | 10-30岁 8000步\n' '步幅 ≈ 身高 × 0.415\n'
'30-50岁 7000步 | 50-70岁 5000步\n' '每日步数根据年龄调整:\n'
'70+岁 3000步\n\n' '<10岁 12000步 | 10-30岁 8000步\n'
'一个成年人一生大约走11万公里\n' '30-50岁 7000步 | 50-70岁 5000步\n'
'相当于绕地球2.75圈!', '70+岁 3000步\n\n'
), '一个成年人一生大约走11万公里\n'
], '相当于绕地球2.75圈!',
),
const SizedBox(height: DesignTokens.space3),
PlanetWeightCard(isDark: isDark, calc: calc),
const SizedBox(height: DesignTokens.space5),
_buildSwipeHint(isDark),
],
),
);
}
Widget _buildSwipeHint(bool isDark) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
CupertinoIcons.chevron_left,
size: 14,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
const SizedBox(width: DesignTokens.space2),
Text(
'👈 左右滑动切换 核心指标 / 科学指数 / 趣味统计 👉',
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
const SizedBox(width: DesignTokens.space2),
Icon(
CupertinoIcons.chevron_right,
size: 14,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
],
),
); );
} }
} }

View File

@@ -3,7 +3,7 @@
* 名称: 身体分析科学指数Tab * 名称: 身体分析科学指数Tab
* 作用: 展示骨骼/血液/去脂体重/理想体重/代谢年龄/体表面积等科学指标 * 作用: 展示骨骼/血液/去脂体重/理想体重/代谢年龄/体表面积等科学指标
* 创建: 2026-04-24 * 创建: 2026-04-24
* 更新: 2026-04-24 从 body_analysis_page.dart 拆分 * 更新: 2026-04-24 修复溢出+增加滑动提示
*/ */
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -23,232 +23,263 @@ class ScienceMetricsTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return SingleChildScrollView(
children: [ physics: const ClampingScrollPhysics(),
MetricCard( child: Column(
isDark: isDark, children: [
emoji: '🦴',
title: '骨骼重量估算',
value: calc.boneWeight.toStringAsFixed(1),
unit: 'kg约体重15%',
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
infoTitle: '骨骼重量',
infoContent:
'人体骨骼约占体重的15%。骨骼是活的组织,不断进行新陈代谢。\n\n'
'骨骼中有206块骨头最大的骨头是股骨最小的是耳中的镫骨。',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '🩸',
title: '血液总量估算',
value: calc.bloodVolume.toStringAsFixed(0),
unit: 'ml',
color: DesignTokens.red,
infoTitle: '血液总量',
infoContent:
'男性约75ml/kg女性约65ml/kg。\n\n'
'血液由血浆55%和血细胞45%)组成。\n'
'红细胞寿命约120天人体每秒产生约200万个新红细胞。',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '💪',
title: '去脂体重 (LBM)',
value: calc.leanBodyMass.toStringAsFixed(1),
unit: 'kg',
color: DesignTokens.blue,
infoTitle: '去脂体重 (Lean Body Mass)',
infoContent:
'去脂体重 = 体重 × (1 - 体脂率%)\n\n'
'LBM包括肌肉、骨骼、器官、水分等非脂肪组织的重量。\n'
'是评估身体成分和计算药物剂量的重要指标。',
),
const SizedBox(height: DesignTokens.space3),
IdealWeightCard(isDark: isDark, calc: calc),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '🧠',
title: '代谢年龄',
value: '${calc.metabolicAge}',
unit: '岁(实际${calc.age.toStringAsFixed(0)}岁)',
color: calc.metabolicAge <= calc.age
? DesignTokens.green
: DesignTokens.orange,
infoTitle: '代谢年龄',
infoContent:
'代谢年龄基于你的BMR与同龄人平均BMR的对比来估算。\n\n'
'代谢年龄 < 实际年龄:代谢更活跃,身体状态较好\n'
'代谢年龄 > 实际年龄:代谢较慢,建议增加运动量',
),
const SizedBox(height: DesignTokens.space3),
if (calc.waistToHeightRatio >= 0) ...[
MetricCard( MetricCard(
isDark: isDark, isDark: isDark,
emoji: '📏', emoji: '🦴',
title: '腰围身高比', title: '骨骼重量估算',
value: calc.waistToHeightRatio.toStringAsFixed(2), value: calc.boneWeight.toStringAsFixed(1),
unit: calc.waistToHeightRatio < 0.5 ? '✅ 健康' : '⚠️ 偏高', unit: 'kg约体重15%',
color: calc.waistToHeightRatio < 0.5 color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
? DesignTokens.green infoTitle: '骨骼重量',
: DesignTokens.orange,
infoTitle: '腰围身高比 (WHtR)',
infoContent: infoContent:
'腰围身高比 = 腰围 / 身高\n\n' '人体骨骼约占体重的15%。骨骼是活的组织,不断进行新陈代谢。\n\n'
'该指标比BMI更能反映内脏脂肪堆积情况。\n' '骨骼中有206块骨头最大的骨头是股骨最小的是耳中的镫骨。',
'健康标准:< 0.5\n' ),
'0.5-0.6:风险增加\n' const SizedBox(height: DesignTokens.space3),
'> 0.6:高风险', MetricCard(
isDark: isDark,
emoji: '🩸',
title: '血液总量估算',
value: calc.bloodVolume.toStringAsFixed(0),
unit: 'ml',
color: DesignTokens.red,
infoTitle: '血液总量',
infoContent:
'男性约75ml/kg女性约65ml/kg。\n\n'
'血液由血浆55%和血细胞45%)组成。\n'
'红细胞寿命约120天人体每秒产生约200万个新红细胞。',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '💪',
title: '去脂体重 (LBM)',
value: calc.leanBodyMass.toStringAsFixed(1),
unit: 'kg',
color: DesignTokens.blue,
infoTitle: '去脂体重 (Lean Body Mass)',
infoContent:
'去脂体重 = 体重 × (1 - 体脂率%)\n\n'
'LBM包括肌肉、骨骼、器官、水分等非脂肪组织的重量。\n'
'是评估身体成分和计算药物剂量的重要指标。',
),
const SizedBox(height: DesignTokens.space3),
IdealWeightCard(isDark: isDark, calc: calc),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '🧠',
title: '代谢年龄',
value: '${calc.metabolicAge}',
unit: '岁(实际${calc.age.toStringAsFixed(0)}岁)',
color: calc.metabolicAge <= calc.age
? DesignTokens.green
: DesignTokens.orange,
infoTitle: '代谢年龄',
infoContent:
'代谢年龄基于你的BMR与同龄人平均BMR的对比来估算。\n\n'
'代谢年龄 < 实际年龄:代谢更活跃,身体状态较好\n'
'代谢年龄 > 实际年龄:代谢较慢,建议增加运动量',
),
const SizedBox(height: DesignTokens.space3),
if (calc.waistToHeightRatio >= 0) ...[
MetricCard(
isDark: isDark,
emoji: '📏',
title: '腰围身高比',
value: calc.waistToHeightRatio.toStringAsFixed(2),
unit: calc.waistToHeightRatio < 0.5 ? '✅ 健康' : '⚠️ 偏高',
color: calc.waistToHeightRatio < 0.5
? DesignTokens.green
: DesignTokens.orange,
infoTitle: '腰围身高比 (WHtR)',
infoContent:
'腰围身高比 = 腰围 / 身高\n\n'
'该指标比BMI更能反映内脏脂肪堆积情况。\n'
'健康标准:< 0.5\n'
'0.5-0.6:风险增加\n'
'> 0.6:高风险',
),
const SizedBox(height: DesignTokens.space3),
],
if (calc.waistToHipRatio >= 0) ...[
MetricCard(
isDark: isDark,
emoji: '📐',
title: '腰臀比 (WHR)',
value: calc.waistToHipRatio.toStringAsFixed(2),
unit: calc.whrCategory,
color: Color(calc.whrColorValue),
infoTitle: '腰臀比 (Waist-to-Hip Ratio)',
infoContent:
'腰臀比 = 腰围 / 臀围\n\n'
'男性:< 0.9 健康 | 0.9-1.0 风险 | > 1.0 高风险\n'
'女性:< 0.8 健康 | 0.8-0.85 风险 | > 0.85 高风险\n\n'
'腰臀比反映脂肪分布,苹果型身材(腹部脂肪多)健康风险更高。',
),
const SizedBox(height: DesignTokens.space3),
],
if (calc.frameSize.isNotEmpty) ...[
MetricCard(
isDark: isDark,
emoji: '🏷️',
title: '骨架类型',
value: calc.frameSize,
unit: '身高/手腕围 = ${calc.frameRatio.toStringAsFixed(1)}',
color: DesignTokens.purple,
infoTitle: '骨架类型',
infoContent:
'基于身高与手腕围的比值判定骨架大小。\n\n'
'男性:> 10.4 小骨架 | 9.6-10.4 中等 | < 9.6 大骨架\n'
'女性:> 11.0 小骨架 | 10.1-11.0 中等 | < 10.1 大骨架\n\n'
'骨架大小会影响理想体重的判定标准。',
),
const SizedBox(height: DesignTokens.space3),
],
MetricCard(
isDark: isDark,
emoji: '🫁',
title: '人体表面积',
value: calc.bodySurfaceArea.toStringAsFixed(2),
unit: '',
color: DesignTokens.teal,
infoTitle: '人体表面积 (BSA)',
infoContent:
'采用Mosteller公式计算\n'
'BSA = √(身高cm × 体重kg / 3600)\n\n'
'人体表面积在医学中用于计算药物剂量、'
'评估烧伤面积和心输出量指数等。',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '💤',
title: '推荐睡眠时长',
value: calc.recommendedSleep.toStringAsFixed(
calc.recommendedSleep % 1 == 0 ? 0 : 1,
),
unit: '小时/天',
color: DesignTokens.purple,
infoTitle: '推荐睡眠时长',
infoContent:
'根据美国国家睡眠基金会建议:\n\n'
'0-1岁: 14-16h | 1-2岁: 13h\n'
'3-5岁: 12h | 6-12岁: 10h\n'
'13-17岁: 9h | 18-25岁: 8h\n'
'26-64岁: 7-8h | 65+: 7-8h\n\n'
'充足睡眠对代谢、免疫和认知功能至关重要。',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '🏋️',
title: '无脂肪质量指数 (FFMI)',
value: calc.ffmi.toStringAsFixed(1),
unit: calc.ffmiCategory,
color: Color(calc.ffmiColorValue),
infoTitle: '无脂肪质量指数 (FFMI)',
infoContent:
'FFMI = 去脂体重(kg) / 身高(m)²\n\n'
'标准化FFMI考虑了身高差异更公平。\n\n'
'男性标准:\n'
'<18 低于平均 | 18-20 平均 | 20-22 高于平均\n'
'22-25 优秀 | 25-28 卓越 | >28 疑似增强剂\n\n'
'女性标准:\n'
'<15 低于平均 | 15-17 平均 | 17-19 高于平均\n'
'19-21 优秀 | 21-24 卓越 | >24 疑似增强剂\n\n'
'FFMI比BMI更适合评估健身人群的肌肉发育水平。',
), ),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
],
if (calc.waistToHipRatio >= 0) ...[
MetricCard( MetricCard(
isDark: isDark, isDark: isDark,
emoji: '📐', emoji: '📐',
title: '腰臀比 (WHR)', title: 'Ponderal 体重指数',
value: calc.waistToHipRatio.toStringAsFixed(2), value: calc.ponderalIndex.toStringAsFixed(1),
unit: calc.whrCategory, unit: 'kg/m³${calc.ponderalCategory}',
color: Color(calc.whrColorValue), color: DesignTokens.blue,
infoTitle: '腰臀比 (Waist-to-Hip Ratio)', infoTitle: 'Ponderal Index (Rohrer指数)',
infoContent: infoContent:
'腰臀比 = 腰围 / 臀围\n\n' 'PI = 体重(kg) / 身高(m)³\n\n'
'男性:< 0.9 健康 | 0.9-1.0 风险 | > 1.0 高风险\n' '与BMI不同Ponderal指数使用身高的三次方\n'
'女性:< 0.8 健康 | 0.8-0.85 风险 | > 0.85 高风险\n\n' '对极端身高(特别高或特别矮)的人群更准确。\n\n'
'腰臀比反映脂肪分布,苹果型身材(腹部脂肪多)健康风险更高。', '男性11-13 正常\n'
'女性10-12.5 正常\n\n'
'PI < 低值 = 偏瘦 | PI > 高值 = 偏胖',
), ),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
],
if (calc.frameSize.isNotEmpty) ...[
MetricCard( MetricCard(
isDark: isDark, isDark: isDark,
emoji: '🏷️', emoji: '',
title: '骨架类型', title: '基础代谢效率',
value: calc.frameSize, value: calc.bmrPerKg.toStringAsFixed(1),
unit: '身高/手腕围 = ${calc.frameRatio.toStringAsFixed(1)}', unit: 'kcal/kg/天(${calc.bmrEfficiency}',
color: DesignTokens.purple, color: DesignTokens.orange,
infoTitle: '骨架类型', infoTitle: '基础代谢效率 (BMR/kg)',
infoContent: infoContent:
'基于身高与手腕围的比值判定骨架大小。\n\n' 'BMR/kg = 基础代谢率 / 体重\n\n'
'男性:> 10.4 小骨架 | 9.6-10.4 中等 | < 9.6 大骨架\n' '反映每公斤体重的基础代谢水平:\n'
'女性:> 11.0 小骨架 | 10.1-11.0 中等 | < 10.1 大骨架\n\n' '< 20 较低 | 20-25 正常\n'
'骨架大小会影响理想体重的判定标准。', '25-30 较高 | > 30 很高\n\n'
'代谢效率高意味着身体在静息状态下消耗更多能量,'
'通常与较好的身体成分和代谢健康相关。',
), ),
const SizedBox(height: DesignTokens.space3), const SizedBox(height: DesignTokens.space3),
if (calc.conicityIndex >= 0) ...[
MetricCard(
isDark: isDark,
emoji: '🔔',
title: '锥度指数 (Conicity Index)',
value: calc.conicityIndex.toStringAsFixed(2),
unit: calc.conicityCategory,
color:
calc.conicityIndex < (calc.gender == Gender.male ? 1.25 : 1.18)
? DesignTokens.green
: DesignTokens.orange,
infoTitle: '锥度指数 (Conicity Index)',
infoContent:
'CI = 腰围 / (0.109 × √(体重/身高))\n\n'
'锥度指数评估腹部脂肪堆积程度,\n'
'将身体形状从"圆柱形"到"圆锥形"量化。\n\n'
'男性:< 1.25 低风险 | 1.25-1.35 中等 | > 1.35 高风险\n'
'女性:< 1.18 低风险 | 1.18-1.28 中等 | > 1.28 高风险\n\n'
'需要腰围数据。',
),
const SizedBox(height: DesignTokens.space3),
],
const SizedBox(height: DesignTokens.space5),
_buildSwipeHint(isDark),
], ],
MetricCard( ),
isDark: isDark, );
emoji: '🫁', }
title: '人体表面积',
value: calc.bodySurfaceArea.toStringAsFixed(2), Widget _buildSwipeHint(bool isDark) {
unit: '', return Padding(
color: DesignTokens.teal, padding: const EdgeInsets.symmetric(vertical: DesignTokens.space4),
infoTitle: '人体表面积 (BSA)', child: Row(
infoContent: mainAxisAlignment: MainAxisAlignment.center,
'采用Mosteller公式计算\n' children: [
'BSA = √(身高cm × 体重kg / 3600)\n\n' Icon(CupertinoIcons.chevron_left,
'人体表面积在医学中用于计算药物剂量、' size: 14,
'评估烧伤面积和心输出量指数等。', color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3),
), const SizedBox(width: DesignTokens.space2),
const SizedBox(height: DesignTokens.space3), Text(
MetricCard( '👈 左右滑动切换 核心指标 / 科学指数 / 趣味统计 👉',
isDark: isDark, style: TextStyle(
emoji: '💤', fontSize: DesignTokens.fontXs,
title: '推荐睡眠时长', color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
value: calc.recommendedSleep.toStringAsFixed( ),
calc.recommendedSleep % 1 == 0 ? 0 : 1,
), ),
unit: '小时/天', const SizedBox(width: DesignTokens.space2),
color: DesignTokens.purple, Icon(CupertinoIcons.chevron_right,
infoTitle: '推荐睡眠时长', size: 14,
infoContent: color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3),
'根据美国国家睡眠基金会建议:\n\n'
'0-1岁: 14-16h | 1-2岁: 13h\n'
'3-5岁: 12h | 6-12岁: 10h\n'
'13-17岁: 9h | 18-25岁: 8h\n'
'26-64岁: 7-8h | 65+: 7-8h\n\n'
'充足睡眠对代谢、免疫和认知功能至关重要。',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '🏋️',
title: '无脂肪质量指数 (FFMI)',
value: calc.ffmi.toStringAsFixed(1),
unit: calc.ffmiCategory,
color: Color(calc.ffmiColorValue),
infoTitle: '无脂肪质量指数 (FFMI)',
infoContent:
'FFMI = 去脂体重(kg) / 身高(m)²\n\n'
'标准化FFMI考虑了身高差异更公平。\n\n'
'男性标准:\n'
'<18 低于平均 | 18-20 平均 | 20-22 高于平均\n'
'22-25 优秀 | 25-28 卓越 | >28 疑似增强剂\n\n'
'女性标准:\n'
'<15 低于平均 | 15-17 平均 | 17-19 高于平均\n'
'19-21 优秀 | 21-24 卓越 | >24 疑似增强剂\n\n'
'FFMI比BMI更适合评估健身人群的肌肉发育水平。',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '📐',
title: 'Ponderal 体重指数',
value: calc.ponderalIndex.toStringAsFixed(1),
unit: 'kg/m³${calc.ponderalCategory}',
color: DesignTokens.blue,
infoTitle: 'Ponderal Index (Rohrer指数)',
infoContent:
'PI = 体重(kg) / 身高(m)³\n\n'
'与BMI不同Ponderal指数使用身高的三次方\n'
'对极端身高(特别高或特别矮)的人群更准确。\n\n'
'男性11-13 正常\n'
'女性10-12.5 正常\n\n'
'PI < 低值 = 偏瘦 | PI > 高值 = 偏胖',
),
const SizedBox(height: DesignTokens.space3),
MetricCard(
isDark: isDark,
emoji: '',
title: '基础代谢效率',
value: calc.bmrPerKg.toStringAsFixed(1),
unit: 'kcal/kg/天(${calc.bmrEfficiency}',
color: DesignTokens.orange,
infoTitle: '基础代谢效率 (BMR/kg)',
infoContent:
'BMR/kg = 基础代谢率 / 体重\n\n'
'反映每公斤体重的基础代谢水平:\n'
'< 20 较低 | 20-25 正常\n'
'25-30 较高 | > 30 很高\n\n'
'代谢效率高意味着身体在静息状态下消耗更多能量,'
'通常与较好的身体成分和代谢健康相关。',
),
const SizedBox(height: DesignTokens.space3),
if (calc.conicityIndex >= 0) ...[
MetricCard(
isDark: isDark,
emoji: '🔔',
title: '锥度指数 (Conicity Index)',
value: calc.conicityIndex.toStringAsFixed(2),
unit: calc.conicityCategory,
color:
calc.conicityIndex < (calc.gender == Gender.male ? 1.25 : 1.18)
? DesignTokens.green
: DesignTokens.orange,
infoTitle: '锥度指数 (Conicity Index)',
infoContent:
'CI = 腰围 / (0.109 × √(体重/身高))\n\n'
'锥度指数评估腹部脂肪堆积程度,\n'
'将身体形状从"圆柱形"到"圆锥形"量化。\n\n'
'男性:< 1.25 低风险 | 1.25-1.35 中等 | > 1.35 高风险\n'
'女性:< 1.18 低风险 | 1.18-1.28 中等 | > 1.28 高风险\n\n'
'需要腰围数据。',
),
const SizedBox(height: DesignTokens.space3),
], ],
], ),
); );
} }
} }