339 lines
11 KiB
Dart
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);
|
|
}
|