Files
Aq-Accounting-Flutter/lib/pages/health/health_home_page.dart
T
2026-09-28 16:40:32 +08:00

651 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '加';
}
}