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 _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 _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 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((p) => p.weight).toList(), ), ), ); } } class _MiniTrendPainter extends CustomPainter { _MiniTrendPainter({required this.values}); final List 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 _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 '加'; } }