强宝健康模块
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user