强宝健康模块
This commit is contained in:
@@ -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 '加';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user