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

489 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../api/health_api.dart';
import '../../providers/health_provider.dart';
import '../../utils/toast.dart';
/// 设置减重目标 + 展示「每周该减多少」。
///
/// 这个页面的核心价值是第二块:把「三个月减 7kg」翻译成「每周 0.54kg」——
/// 前者没有可执行性,后者才有。
class GoalSetupPage extends ConsumerStatefulWidget {
const GoalSetupPage({super.key});
@override
ConsumerState<GoalSetupPage> createState() => _GoalSetupPageState();
}
class _GoalSetupPageState extends ConsumerState<GoalSetupPage> {
final _startCtrl = TextEditingController();
final _targetCtrl = TextEditingController();
DateTime _startDate = DateTime.now();
DateTime _targetDate = DateTime.now().add(const Duration(days: 90));
bool _saving = false;
bool _loaded = false;
@override
void initState() {
super.initState();
// 已有目标就预填,让「想改一改」不用重敲
Future.microtask(() async {
try {
final goal = await ref.read(healthApiProvider).goal();
if (!mounted) return;
if (goal.hasGoal) {
setState(() {
_startCtrl.text = _num(goal.startWeight);
_targetCtrl.text = _num(goal.targetWeight);
_startDate = goal.startDate ?? _startDate;
_targetDate = goal.targetDate ?? _targetDate;
});
} else {
// 没设过目标时,用最近一次体重当起始值
final chart = await ref.read(healthApiProvider).weightChart();
if (!mounted) return;
final current = chart.currentWeight;
if (current != null) {
setState(() => _startCtrl.text = _num(current));
}
}
} catch (_) {
// 拿不到就不预填,用户手填即可
} finally {
if (mounted) setState(() => _loaded = true);
}
});
}
@override
void dispose() {
_startCtrl.dispose();
_targetCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7F4F1),
appBar: AppBar(
title: const Text('减重目标'),
backgroundColor: const Color(0xFFF7F4F1),
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: const Color(0xFF5D4037),
),
body: !_loaded
? const Center(
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
)
: ListView(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
children: [
_formCard(),
const SizedBox(height: 12),
_planCard(),
const SizedBox(height: 12),
_weeklyTableCard(),
const SizedBox(height: 20),
_saveButton(),
],
),
);
}
// ---------------- 输入区 ----------------
Widget _formCard() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: _numberField(
controller: _startCtrl,
label: '起始体重',
hint: '65.0',
),
),
const SizedBox(width: 14),
Expanded(
child: _numberField(
controller: _targetCtrl,
label: '目标体重',
hint: '58.0',
),
),
],
),
const SizedBox(height: 4),
_dateRow('开始日期', _startDate,
(d) => setState(() => _startDate = d)),
_dateRow('目标日期', _targetDate,
(d) => setState(() => _targetDate = d)),
const SizedBox(height: 4),
],
),
);
}
Widget _numberField({
required TextEditingController controller,
required String label,
required String hint,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
const SizedBox(height: 4),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => setState(() {}),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037)),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFFD7CCC8)),
suffixText: 'kg',
suffixStyle:
const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFFE0D5CC)),
),
focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Color(0xFFC8956E), width: 1.6),
),
),
),
],
);
}
Widget _dateRow(String label, DateTime value, ValueChanged<DateTime> onPick) {
return InkWell(
onTap: () => _pickDate(value, onPick),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
Text(label,
style: const TextStyle(
fontSize: 13, color: Color(0xFF8D6E63))),
const Spacer(),
Text(
_dateText(value),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right,
size: 18, color: Color(0xFFBCAAA4)),
],
),
),
);
}
Future<void> _pickDate(
DateTime initial, ValueChanged<DateTime> onPick) async {
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2020),
lastDate: DateTime(2035),
);
if (picked != null) onPick(picked);
}
// ---------------- 计划预览 ----------------
/// 本地先算一遍计划,让用户改数字时立刻看到「每周该减多少」,
/// 不用等保存。算法和后端一致(总周数向上取整)。
({int weeks, double weeklyLoss, bool valid}) _computePlan() {
final start = double.tryParse(_startCtrl.text.trim());
final target = double.tryParse(_targetCtrl.text.trim());
if (start == null || target == null || start <= target) {
return (weeks: 0, weeklyLoss: 0, valid: false);
}
final days = _targetDate.difference(_startDate).inDays;
if (days <= 0) return (weeks: 0, weeklyLoss: 0, valid: false);
final weeks = (days / 7).ceil();
final weekly = (start - target) / weeks;
return (weeks: weeks, weeklyLoss: weekly, valid: true);
}
Widget _planCard() {
final plan = _computePlan();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: !plan.valid
? const Text(
'填好起始体重和目标体重\n(目标要低于起始)就能算出每周该减多少',
style: TextStyle(
fontSize: 13, height: 1.7, color: Color(0xFFBCAAA4)),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('按这个目标',
style: TextStyle(
fontSize: 12, color: Color(0xFF8D6E63))),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'每周 ${plan.weeklyLoss.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Color(0xFFC8956E),
),
),
const SizedBox(width: 4),
const Text('kg',
style: TextStyle(
fontSize: 14, color: Color(0xFF8D6E63))),
const Spacer(),
Text(
'共 ${plan.weeks} 周',
style: const TextStyle(
fontSize: 12.5, color: Color(0xFF8D6E63)),
),
],
),
if (plan.weeklyLoss > 1) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 8),
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,
height: 1.5,
color: Color(0xFF8D6E63)),
),
),
],
),
),
],
],
),
);
}
/// 每周目标体重表 —— 让「每周 0.54kg」变成一串能对照的数字
Widget _weeklyTableCard() {
final plan = _computePlan();
if (!plan.valid) return const SizedBox.shrink();
final start = double.parse(_startCtrl.text.trim());
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 6),
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: 4),
const Text(
'对照着看,就能知道自己落后了还是超前了',
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
const SizedBox(height: 10),
// 只列前 8 周和最后一周:全列出来(13 周以上)会很长且没人看
..._weekRows(start, plan.weeks, plan.weeklyLoss),
],
),
);
}
List<Widget> _weekRows(double start, int weeks, double weekly) {
final rows = <Widget>[];
final showAll = weeks <= 8;
final indices = showAll
? List<int>.generate(weeks, (i) => i + 1)
: [1, 2, 3, 4, 5, 6, 7, 8, weeks];
int? previous;
for (final week in indices) {
// 中间省略时插一行省略号
if (previous != null && week != previous + 1) {
rows.add(const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Center(
child: Text('···',
style: TextStyle(color: Color(0xFFBCAAA4), fontSize: 13)),
),
));
}
previous = week;
final ideal = start - weekly * week;
rows.add(
Padding(
padding: const EdgeInsets.only(bottom: 9),
child: Row(
children: [
SizedBox(
width: 62,
child: Text(
'第 $week 周',
style: const TextStyle(
fontSize: 12.5, color: Color(0xFF8D6E63)),
),
),
Expanded(
child: Text(
'${ideal.toStringAsFixed(1)} kg',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
),
Text(
'累计减 ${(weekly * week).toStringAsFixed(1)} kg',
style: const TextStyle(
fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
],
),
),
);
}
return rows;
}
// ---------------- 保存 ----------------
Widget _saveButton() {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFC8956E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: Text(
_saving ? '保存中…' : '保存目标',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
);
}
Future<void> _save() async {
final start = double.tryParse(_startCtrl.text.trim());
final target = double.tryParse(_targetCtrl.text.trim());
if (start == null || target == null) {
showToast(context, '请填写起始体重和目标体重', error: true);
return;
}
if (start < 20 || start > 300 || target < 20 || target > 300) {
showToast(context, '体重需在 20 ~ 300 kg 之间', error: true);
return;
}
if (target >= start) {
showToast(context, '目标体重要低于起始体重', error: true);
return;
}
if (!_targetDate.isAfter(_startDate)) {
showToast(context, '目标日期要晚于开始日期', error: true);
return;
}
setState(() => _saving = true);
try {
await ref.read(healthApiProvider).saveGoal(
startWeight: start,
targetWeight: target,
startDate: _startDate,
targetDate: _targetDate,
);
if (!mounted) return;
ref.invalidate(weightGoalProvider);
ref.invalidate(weightChartProvider);
showToast(context, '目标已保存', icon: 'check');
Navigator.of(context).pop();
} catch (e) {
if (!mounted) return;
showToast(context, e.toString(), error: true);
} finally {
if (mounted) setState(() => _saving = false);
}
}
}
String _num(num value) {
if (value == value.roundToDouble()) return value.round().toString();
return value.toStringAsFixed(1);
}
String _dateText(DateTime date) {
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '${date.year}-$m-$d';
}