489 lines
16 KiB
Dart
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';
|
|
}
|