强宝健康模块

This commit is contained in:
2026-09-28 16:40:32 +08:00
parent d52775ec10
commit 2dd89bfde0
17 changed files with 4271 additions and 1 deletions
+650
View File
@@ -0,0 +1,650 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/health.dart';
import '../../providers/health_provider.dart';
import 'diet_page.dart';
import 'goal_setup_page.dart';
import 'weight_page.dart';
import 'widgets/health_card.dart';
/// 「强宝健康」模块入口页。
///
/// 三个卡片就是三件事,按用户关心的顺序排:
/// ① 减肥进度(目标层面:还差多少、节奏对不对)
/// ② 当前体重(日常层面:今天称了没、趋势如何)
/// ③ 今日饮食(执行层面:吃了多少)
class HealthHomePage extends ConsumerWidget {
const HealthHomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: const Color(0xFFF7F4F1),
appBar: AppBar(
title: const Text('强宝健康'),
backgroundColor: const Color(0xFFF7F4F1),
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: const Color(0xFF5D4037),
),
body: RefreshIndicator(
color: const Color(0xFFC8956E),
onRefresh: () async {
ref.invalidate(weightGoalProvider);
ref.invalidate(weightChartProvider);
ref.invalidate(dietTodayProvider);
// 等目标那一路拉完再收起转圈 —— 它是三个卡片里最先渲染的
try {
await ref.read(weightGoalProvider.future);
} catch (_) {
// 失败就失败,RefreshIndicator 该收起来
}
},
child: ListView(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
children: const [
_GoalCard(),
SizedBox(height: 12),
_WeightCard(),
SizedBox(height: 12),
_DietCard(),
],
),
),
);
}
}
// ==================================================================
// ① 减肥进度
// ==================================================================
class _GoalCard extends ConsumerWidget {
const _GoalCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(weightGoalProvider);
final goal = async.value;
// 加载中和出错都退化成「还没设目标」的形态 —— 卡片不该变成一块错误提示,
// 用户想做的只是点进去设个目标
if (goal == null || !goal.hasGoal) {
return HealthCard(
title: '减肥进度',
onTap: () => _openGoal(context, ref),
child: async.isLoading
? const _CardLoading()
: const _GoalEmpty(),
);
}
final current = goal.currentWeight;
final lost = goal.lostWeight;
final remain = goal.remainWeight;
return HealthCard(
title: '减肥进度',
onTap: () => _openGoal(context, ref),
trailing: Text(
'目标 ${_num(goal.targetWeight)} kg',
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
current == null ? '--' : _num(current),
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 3),
const Text('kg',
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
const SizedBox(width: 8),
// Expanded + 右对齐:小屏上「已减 x kg」会被压窄,
// 但不能把左边的体重数字顶出去
Expanded(
child: Text(
lost == null ? '' : '已减 ${_num(lost)} kg',
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF4FA98A),
),
),
),
],
),
const SizedBox(height: 10),
HealthProgressBar(value: goal.weightProgress),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
remain == null
? '还没记体重,记一次就能看进度'
: '还差 ${_num(remain)} kg',
maxLines: 2,
style: const TextStyle(
fontSize: 12, color: Color(0xFF8D6E63)),
),
),
const SizedBox(width: 8),
// 单独一行放「每周应减」,不和「还差」挤同一个方向
Flexible(
child: Text(
'每周应减 ${_num(goal.weeklyLoss)} kg',
textAlign: TextAlign.right,
maxLines: 2,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
// 每周超过 1kg 通常是掉肌肉和水,标黄提示
color: goal.weeklyLossUnhealthy
? const Color(0xFFD9A93C)
: const Color(0xFF8D6E63),
),
),
),
],
),
if (goal.weeklyLossUnhealthy) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFF8ECD2),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(Icons.info_outline, size: 14, color: Color(0xFFD9A93C)),
SizedBox(width: 6),
Expanded(
child: Text(
'节奏偏快,每周超过 1kg 掉的往往是肌肉和水分',
style: TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)),
),
),
],
),
),
],
],
),
);
}
Future<void> _openGoal(BuildContext context, WidgetRef ref) async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const GoalSetupPage()),
);
ref.invalidate(weightGoalProvider);
ref.invalidate(weightChartProvider);
}
}
class _GoalEmpty extends StatelessWidget {
const _GoalEmpty();
@override
Widget build(BuildContext context) {
return Row(
children: [
const Expanded(
child: Text(
'还没设目标\n设一个,就有每周该减多少了',
style: TextStyle(
fontSize: 13, height: 1.6, color: Color(0xFFBCAAA4)),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFC8956E).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'去设置',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFFC8956E),
),
),
),
],
);
}
}
// ==================================================================
// ② 当前体重
// ==================================================================
class _WeightCard extends ConsumerWidget {
const _WeightCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(weightChartProvider);
final chart = async.value;
final delta = chart?.delta;
return HealthCard(
title: '当前体重',
onTap: () => _openWeight(context, ref),
trailing: delta == null
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
delta > 0 ? Icons.arrow_upward : Icons.arrow_downward,
size: 13,
// 涨了用红、掉了用绿 —— 这个模块的语境就是「掉是好事」
color: delta > 0
? const Color(0xFFE0725C)
: const Color(0xFF4FA98A),
),
Text(
'${_num(delta.abs())} kg',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: delta > 0
? const Color(0xFFE0725C)
: const Color(0xFF4FA98A),
),
),
],
),
child: async.isLoading
? const _CardLoading()
: chart == null || !chart.hasData
? const _WeightEmpty()
: _WeightBody(chart: chart),
);
}
Future<void> _openWeight(BuildContext context, WidgetRef ref) async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const WeightPage()),
);
ref.invalidate(weightChartProvider);
ref.invalidate(weightGoalProvider);
}
}
class _WeightBody extends StatelessWidget {
const _WeightBody({required this.chart});
final WeightChart chart;
@override
Widget build(BuildContext context) {
final current = chart.currentWeight;
final points = chart.points;
final last = points.isEmpty ? null : points.last;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
current == null ? '--' : _num(current),
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 3),
const Text('kg',
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
const SizedBox(width: 8),
if (last != null)
Expanded(
child: Text(
'最近 ${_shortDate(last.date)}',
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12, color: Color(0xFFBCAAA4)),
),
),
],
),
const SizedBox(height: 10),
// 卡片里只给迷你趋势,看完整图表点进详情页
_MiniTrend(points: points),
],
);
}
}
/// 卡片内嵌的迷你折线(不引图表库:这里只要一条走势线,
/// fl_chart 在这么小的尺寸里坐标轴反而占掉一半空间)
class _MiniTrend extends StatelessWidget {
const _MiniTrend({required this.points});
final List<WeightPoint> points;
@override
Widget build(BuildContext context) {
if (points.length < 2) {
return const SizedBox(
height: 46,
child: Center(
child: Text(
'再记一次就能看出趋势了',
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
),
),
);
}
return SizedBox(
height: 46,
width: double.infinity,
child: CustomPaint(
painter: _MiniTrendPainter(
values: points.map<double>((p) => p.weight).toList(),
),
),
);
}
}
class _MiniTrendPainter extends CustomPainter {
_MiniTrendPainter({required this.values});
final List<double> values;
@override
void paint(Canvas canvas, Size size) {
if (values.length < 2) return;
var min = values.first;
var max = values.first;
for (final v in values) {
if (v < min) min = v;
if (v > max) max = v;
}
// 上下各留 20% 余量,否则线会贴着边框
final span = (max - min).abs() < 0.1 ? 1.0 : (max - min);
final pad = span * 0.2;
final dx = size.width / (values.length - 1);
final path = Path();
for (var i = 0; i < values.length; i++) {
// 体重掉下去应该往下走 —— Y 轴要翻转(屏幕坐标 y 向下增长)
final ratio = (values[i] - (min - pad)) / (span + pad * 2);
final y = size.height - ratio * size.height;
final x = dx * i;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(
path,
Paint()
..color = const Color(0xFFC8956E)
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round,
);
// 末端点一个实心圆,提示「这里是现在」
final lastRatio = (values.last - (min - pad)) / (span + pad * 2);
canvas.drawCircle(
Offset(size.width, size.height - lastRatio * size.height),
3.2,
Paint()..color = const Color(0xFFC8956E),
);
}
@override
bool shouldRepaint(covariant _MiniTrendPainter old) =>
old.values != values;
}
class _WeightEmpty extends StatelessWidget {
const _WeightEmpty();
@override
Widget build(BuildContext context) {
return const Row(
children: [
Expanded(
child: Text(
'还没记过体重\n点这里记一下,就能看趋势',
style: TextStyle(
fontSize: 13, height: 1.6, color: Color(0xFFBCAAA4)),
),
),
Icon(Icons.add_circle_outline, size: 26, color: Color(0xFFC8956E)),
],
);
}
}
// ==================================================================
// ③ 今日饮食
// ==================================================================
class _DietCard extends ConsumerWidget {
const _DietCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(dietTodayProvider);
final day = async.value;
final kcal = day?.totals.energyKcal ?? 0;
final count = day?.totals.itemCount ?? 0;
return HealthCard(
title: '今日饮食',
onTap: () => _openDiet(context, ref),
trailing: count == 0
? null
: Text(
'$count 条',
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
),
action: GestureDetector(
onTap: () => _openDiet(context, ref),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: const Color(0xFFC8956E).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, size: 16, color: Color(0xFFC8956E)),
SizedBox(width: 4),
Text(
'记一笔',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFFC8956E),
),
),
],
),
),
),
child: async.isLoading
? const _CardLoading()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_num(kcal),
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 3),
const Text('kcal',
style: TextStyle(
fontSize: 13, color: Color(0xFF8D6E63))),
],
),
const SizedBox(height: 10),
if (day == null || count == 0)
const Text(
'今天还没记,点这里加一条',
style:
TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
)
else
// 只列有内容的餐次,空餐次不占位
...day.meals
.where((m) => m.items.isNotEmpty)
.map((m) => _MealLine(group: m)),
],
),
);
}
Future<void> _openDiet(BuildContext context, WidgetRef ref) async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DietPage()),
);
ref.invalidate(dietTodayProvider);
}
}
class _MealLine extends StatelessWidget {
const _MealLine({required this.group});
final MealGroup group;
@override
Widget build(BuildContext context) {
final names = group.items
.map((e) => '${e.foodName}(${_num(e.amountGrams)}g)')
.join('、');
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 1),
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFFF0EAE4),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_mealLabel(group.mealType),
style: const TextStyle(
fontSize: 10.5, color: Color(0xFF8D6E63)),
),
),
const SizedBox(width: 7),
Expanded(
child: Text(
names,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5, height: 1.5, color: Color(0xFF5D4037)),
),
),
const SizedBox(width: 6),
Text(
'${_num(group.totals.energyKcal)} kcal',
style: const TextStyle(
fontSize: 11.5,
color: Color(0xFF8D6E63),
),
),
],
),
);
}
}
// ==================================================================
class _CardLoading extends StatelessWidget {
const _CardLoading();
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 56,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFFC8956E)),
),
),
);
}
}
/// 数字去掉多余的小数位:65.0 → 65,65.5 → 65.5
String _num(num value) {
if (value == value.roundToDouble()) return value.round().toString();
return value.toStringAsFixed(1);
}
String _shortDate(DateTime date) {
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$m-$d';
}
String _mealLabel(String mealType) {
switch (mealType) {
case 'breakfast':
return '早';
case 'lunch':
return '午';
case 'dinner':
return '晚';
default:
return '加';
}
}