import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../api/health_api.dart'; import '../../models/health.dart'; import '../../providers/health_provider.dart'; import '../../utils/toast.dart'; import '../../widgets/state_views.dart'; import 'widgets/weight_line_chart.dart'; /// 体重页面:记体重 + 看折线图 + 看历史。 /// /// 顶部一个「记一下」输入区放着不动,下面依次是图表和历史 —— /// 这个页面最高频的动作就是「记一次」,它必须在第一屏、不用滚动就能点到。 class WeightPage extends ConsumerStatefulWidget { const WeightPage({super.key}); @override ConsumerState createState() => _WeightPageState(); } class _WeightPageState extends ConsumerState { final _ctrl = TextEditingController(); final _noteCtrl = TextEditingController(); bool _saving = false; DateTime _date = DateTime.now(); @override void initState() { super.initState(); // 预填最近一次体重 —— 大部分时候体重变化很小,改一下比重新敲快 Future.microtask(() async { try { final chart = await ref.read(healthApiProvider).weightChart(); if (!mounted) return; final current = chart.currentWeight; if (current != null) { setState(() => _ctrl.text = _fmt(current)); } } catch (_) { // 拿不到就留空手填 } }); } @override void dispose() { _ctrl.dispose(); _noteCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final chartAsync = ref.watch(weightChartProvider); final goalAsync = ref.watch(weightGoalProvider); return Scaffold( backgroundColor: const Color(0xFFF7F4F1), appBar: AppBar( title: const Text('体重记录'), backgroundColor: const Color(0xFFF7F4F1), elevation: 0, scrolledUnderElevation: 0, foregroundColor: const Color(0xFF5D4037), ), body: ListView( padding: const EdgeInsets.fromLTRB(14, 6, 14, 24), children: [ _inputCard(), const SizedBox(height: 12), _chartCard(chartAsync, goalAsync.value), const SizedBox(height: 12), _historyCard(), ], ), ); } // ---------------- 输入区 ---------------- Widget _inputCard() { return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text('记录体重', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), )), const Spacer(), // 日期默认今天,要补记以前的就点这里改 GestureDetector( onTap: _pickDate, child: Row( children: [ Text( _isToday(_date) ? '今天' : _dateText(_date), style: const TextStyle( fontSize: 12.5, color: Color(0xFFC8956E)), ), const Icon(Icons.chevron_right, size: 16, color: Color(0xFFC8956E)), ], ), ), ], ), const SizedBox(height: 6), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: TextField( controller: _ctrl, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: const TextStyle( fontSize: 34, fontWeight: FontWeight.bold, color: Color(0xFF5D4037), ), decoration: const InputDecoration( hintText: '65.0', hintStyle: TextStyle( fontSize: 34, fontWeight: FontWeight.bold, color: Color(0xFFEFE5DC)), suffixText: 'kg', suffixStyle: TextStyle(fontSize: 14, color: Color(0xFF8D6E63)), isDense: true, contentPadding: EdgeInsets.symmetric(vertical: 8), border: InputBorder.none, ), ), ), const SizedBox(width: 10), ElevatedButton( onPressed: _saving ? null : _save, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFC8956E), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 22, vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(22), ), ), child: Text(_saving ? '…' : '记录'), ), ], ), const SizedBox(height: 4), TextField( controller: _noteCtrl, style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037)), decoration: const InputDecoration( hintText: '备注(可选),比如「早上空腹」', hintStyle: TextStyle(fontSize: 12.5, color: Color(0xFFBCAAA4)), isDense: true, contentPadding: EdgeInsets.symmetric(vertical: 6), border: InputBorder.none, ), ), ], ), ); } Future _pickDate() async { final picked = await showDatePicker( context: context, initialDate: _date, firstDate: DateTime(2020), lastDate: DateTime.now(), ); if (picked != null) setState(() => _date = picked); } Future _save() async { final weight = double.tryParse(_ctrl.text.trim()); if (weight == null) { showToast(context, '请输入体重', error: true); return; } if (weight < 20 || weight > 300) { showToast(context, '体重需在 20 ~ 300 kg 之间', error: true); return; } setState(() => _saving = true); try { await ref.read(healthApiProvider).saveWeight( weight: weight, date: _date, note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(), ); if (!mounted) return; ref.invalidate(weightChartProvider); ref.invalidate(weightHistoryProvider); ref.invalidate(weightGoalProvider); FocusScope.of(context).unfocus(); setState(() => _noteCtrl.clear()); showToast(context, '已记录', icon: 'check'); } catch (e) { if (!mounted) return; showToast(context, e.toString(), error: true); } finally { if (mounted) setState(() => _saving = false); } } // ---------------- 图表 ---------------- Widget _chartCard(AsyncValue chartAsync, WeightGoal? goal) { final chart = chartAsync.value; return Container( padding: const EdgeInsets.fromLTRB(10, 14, 16, 10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.only(left: 6), child: Text('体重变化', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), )), ), if (chart != null && chart.hasData && goal != null && goal.hasGoal) Padding( padding: const EdgeInsets.only(left: 6, top: 6), child: Row( children: [ _legend(const Color(0xFFC8956E), '实测'), const SizedBox(width: 14), _legend(const Color(0xFF4FA98A), '理想'), ], ), ), const SizedBox(height: 8), if (chartAsync.isLoading) const SizedBox(height: 220, child: LoadingView(tip: '')) else if (chartAsync.hasError) SizedBox( height: 220, child: Center( child: ErrorView( message: '图表加载失败', onRetry: () => ref.invalidate(weightChartProvider), ), ), ) else WeightLineChart( points: chart?.points ?? const [], idealCurve: goal?.idealCurve ?? const [], ), ], ), ); } Widget _legend(Color color, String label) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 14, height: 2.5, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 5), Text(label, style: const TextStyle( fontSize: 11.5, color: Color(0xFF8D6E63))), ], ); } // ---------------- 历史 ---------------- Widget _historyCard() { final async = ref.watch(weightHistoryProvider); return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('历史记录', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), )), const SizedBox(height: 8), switch (async) { AsyncValue(hasError: true) => const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Text('加载失败', style: TextStyle( fontSize: 13, color: Color(0xFFBCAAA4))), ), AsyncValue(:final value?) when value.isEmpty => const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Text('还没有记录', style: TextStyle( fontSize: 13, color: Color(0xFFBCAAA4))), ), AsyncValue(:final value?) => _historyList(value), _ => const SizedBox( height: 60, child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Color(0xFFC8956E)), ), ), ), }, ], ), ); } Widget _historyList(List items) { // 相邻两条的差值,让「掉了多少」一眼可见 return Column( children: [ for (var i = 0; i < items.length; i++) _historyRow(items, i), ], ); } Widget _historyRow(List items, int index) { final item = items[index]; // 历史是倒序的,所以「上一次」是后面那条 final previous = index + 1 < items.length ? items[index + 1] : null; final diff = previous == null ? null : item.weight - previous.weight; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ SizedBox( width: 74, child: Text( item.date == null ? '--' : _dateShort(item.date!), style: const TextStyle( fontSize: 12.5, color: Color(0xFF8D6E63)), ), ), Expanded( child: Row( children: [ Text( '${_fmt(item.weight)} kg', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), if (diff != null && diff.abs() >= 0.05) ...[ const SizedBox(width: 8), Text( // 掉了给绿色,涨了给红 —— 这个模块的语境界定了颜色方向 '${diff > 0 ? '+' : ''}${diff.toStringAsFixed(1)}', style: TextStyle( fontSize: 11.5, color: diff > 0 ? const Color(0xFFE0725C) : const Color(0xFF4FA98A), ), ), ], ], ), ), if (item.note.isNotEmpty) Flexible( child: Text( item.note, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 11.5, color: Color(0xFFBCAAA4)), ), ), IconButton( onPressed: () => _confirmDelete(item), icon: const Icon(Icons.close, size: 16), color: const Color(0xFFD7CCC8), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 30, minHeight: 30), ), ], ), ); } Future _confirmDelete(WeightRecordItem item) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('删除这条记录?'), content: Text( '${item.date == null ? '' : _dateShort(item.date!)} · ${_fmt(item.weight)} kg'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.of(ctx).pop(true), style: TextButton.styleFrom( foregroundColor: const Color(0xFFE53935)), child: const Text('删除'), ), ], ), ); if (ok != true) return; try { await ref.read(healthApiProvider).deleteWeight(item.id); if (!mounted) return; ref.invalidate(weightHistoryProvider); ref.invalidate(weightChartProvider); ref.invalidate(weightGoalProvider); showToast(context, '已删除', icon: 'check'); } catch (e) { if (!mounted) return; showToast(context, e.toString(), error: true); } } } // ================================================================== String _fmt(num value) { if (value == value.roundToDouble()) return value.round().toString(); return value.toStringAsFixed(1); } bool _isToday(DateTime date) { final now = DateTime.now(); return date.year == now.year && date.month == now.month && date.day == now.day; } String _dateText(DateTime date) => '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; String _dateShort(DateTime date) => '${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';