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

339 lines
11 KiB
Dart

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 'food_picker_page.dart';
/// 今日饮食:营养汇总 + 按餐次分组明细 + 增删改。
class DietPage extends ConsumerStatefulWidget {
const DietPage({super.key});
@override
ConsumerState<DietPage> createState() => _DietPageState();
}
class _DietPageState extends ConsumerState<DietPage> {
Future<void> _addTo(String mealType) async {
final added = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => FoodPickerPage(mealType: mealType),
),
);
if (added == true) {
ref.invalidate(dietTodayProvider);
ref.invalidate(dietTodayTotalsProvider);
}
}
@override
Widget build(BuildContext context) {
final async = ref.watch(dietTodayProvider);
return Scaffold(
backgroundColor: const Color(0xFFF7F4F1),
appBar: AppBar(
title: const Text('今日饮食'),
backgroundColor: const Color(0xFFF7F4F1),
elevation: 0,
scrolledUnderElevation: 0,
foregroundColor: const Color(0xFF5D4037),
),
body: switch (async) {
AsyncValue(hasError: true) => Center(
child: ErrorView(
message: '饮食记录加载失败',
onRetry: () => ref.invalidate(dietTodayProvider),
),
),
AsyncValue(:final value?) => RefreshIndicator(
color: const Color(0xFFC8956E),
onRefresh: () async {
ref.invalidate(dietTodayProvider);
await ref.read(dietTodayProvider.future).catchError((_) => DietDay.empty);
},
child: _body(value),
),
_ => const Center(
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
),
},
);
}
Widget _body(DietDay day) {
return ListView(
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
children: [
_summaryCard(day.totals),
const SizedBox(height: 12),
for (final meal in day.meals) ...[
_mealCard(meal),
const SizedBox(height: 12),
],
],
);
}
// ---------------- 营养汇总 ----------------
Widget _summaryCard(NutritionTotals totals) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('今日摄入',
style: TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_fmt(totals.energyKcal),
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 4),
const Text('kcal',
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
const Spacer(),
Text(
'${totals.itemCount} 条记录',
style: const TextStyle(
fontSize: 12, color: Color(0xFFBCAAA4)),
),
],
),
const SizedBox(height: 14),
// 营养看板只显示这 5 项 —— 表里有 36 个字段,
// 胡萝卜素、碘、锰这些日常记录用不上,全摆出来只会糊住
Row(
children: [
Expanded(child: _nutrient('蛋白质', totals.protein, 'g')),
Expanded(child: _nutrient('脂肪', totals.fat, 'g')),
Expanded(child: _nutrient('碳水', totals.carbohydrate, 'g')),
Expanded(child: _nutrient('纤维', totals.dietaryFiber, 'g')),
Expanded(child: _nutrient('钠', totals.sodium, 'mg')),
],
),
],
),
);
}
Widget _nutrient(String label, double value, String unit) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 11, color: Color(0xFF8D6E63))),
const SizedBox(height: 3),
Text(
'${_fmt(value)}$unit',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
],
);
}
// ---------------- 餐次 ----------------
Widget _mealCard(MealGroup meal) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 10, 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
mealLabel(meal.mealType),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 8),
if (meal.items.isNotEmpty)
Text(
'${_fmt(meal.totals.energyKcal)} kcal',
style: const TextStyle(
fontSize: 12, color: Color(0xFF8D6E63)),
),
const Spacer(),
TextButton.icon(
onPressed: () => _addTo(meal.mealType),
icon: const Icon(Icons.add, size: 15),
label: const Text('添加', style: TextStyle(fontSize: 13)),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFC8956E),
padding: const EdgeInsets.symmetric(horizontal: 8),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
if (meal.items.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 4, bottom: 6),
child: Text('还没记',
style: TextStyle(
fontSize: 12.5, color: Color(0xFFBCAAA4))),
)
else
...meal.items.map((item) => _dietRow(item)),
],
),
);
}
Widget _dietRow(DietRecordItem item) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Expanded(
child: Text(
item.foodName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5, color: Color(0xFF5D4037)),
),
),
const SizedBox(width: 8),
// 点份量可以改克数
GestureDetector(
onTap: () => _editAmount(item),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF7F4F1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${_fmt(item.amountGrams)}g',
style: const TextStyle(
fontSize: 11.5, color: Color(0xFF8D6E63)),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 62,
child: Text(
'${_fmt(item.energyKcal)} kcal',
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 11.5, color: Color(0xFF8D6E63)),
),
),
IconButton(
onPressed: () => _delete(item),
icon: const Icon(Icons.close, size: 15),
color: const Color(0xFFD7CCC8),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
],
),
);
}
Future<void> _editAmount(DietRecordItem item) async {
final ctrl = TextEditingController(text: _fmt(item.amountGrams));
final grams = await showDialog<double>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(item.foodName, style: const TextStyle(fontSize: 16)),
content: TextField(
controller: ctrl,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
suffixText: 'g',
labelText: '份量',
helperText: '营养值会跟着重算',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
TextButton(
onPressed: () {
final value = double.tryParse(ctrl.text.trim());
Navigator.of(ctx).pop(value);
},
child: const Text('确定'),
),
],
),
);
ctrl.dispose();
if (grams == null) return;
if (grams <= 0) {
if (!mounted) return;
showToast(context, '份量必须大于 0', error: true);
return;
}
try {
await ref.read(healthApiProvider).updateDietAmount(item.id, grams);
if (!mounted) return;
ref.invalidate(dietTodayProvider);
ref.invalidate(dietTodayTotalsProvider);
} catch (e) {
if (!mounted) return;
showToast(context, e.toString(), error: true);
}
}
Future<void> _delete(DietRecordItem item) async {
try {
await ref.read(healthApiProvider).deleteDiet(item.id);
if (!mounted) return;
ref.invalidate(dietTodayProvider);
ref.invalidate(dietTodayTotalsProvider);
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);
}