强宝健康模块
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);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
|
||||
/// 食物选择器:搜索 + 分类浏览 → 选份量 → 记入指定餐次。
|
||||
///
|
||||
/// 两层导航:分类列表 → 分类下的食物。搜索框常驻在顶部,
|
||||
/// 因为「知道吃什么」的时候搜索最快,「不知道」的时候才需要翻分类。
|
||||
class FoodPickerPage extends ConsumerStatefulWidget {
|
||||
const FoodPickerPage({super.key, required this.mealType});
|
||||
|
||||
/// 记到哪一餐
|
||||
final String mealType;
|
||||
|
||||
@override
|
||||
ConsumerState<FoodPickerPage> createState() => _FoodPickerPageState();
|
||||
}
|
||||
|
||||
class _FoodPickerPageState extends ConsumerState<FoodPickerPage> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
|
||||
String _keyword = '';
|
||||
Timer? _debounce;
|
||||
|
||||
/// 当前浏览的分类。null = 显示分类列表。
|
||||
String? _category;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: Text('添加到${mealLabel(widget.mealType)}'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_searchBar(),
|
||||
if (_category != null) _categoryBreadcrumb(),
|
||||
Expanded(child: _content()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _searchBar() {
|
||||
return Container(
|
||||
color: const Color(0xFFF7F4F1),
|
||||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 10),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
onChanged: _onSearchChanged,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜食物名,比如「米饭」「鸡蛋」',
|
||||
hintStyle:
|
||||
const TextStyle(fontSize: 13.5, color: Color(0xFFBCAAA4)),
|
||||
prefixIcon: const Icon(Icons.search,
|
||||
size: 19, color: Color(0xFFBCAAA4)),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
_searchCtrl.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
child: const Icon(Icons.close,
|
||||
size: 17, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 11),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索时显示「返回分类」的入口 —— 否则搜过一次就回不到分类浏览了
|
||||
Widget _categoryBreadcrumb() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFF7F4F1),
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _category = null),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.arrow_back_ios_new,
|
||||
size: 11, color: Color(0xFFC8956E)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_category!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text('· 全部分类',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 320), () {
|
||||
if (!mounted) return;
|
||||
setState(() => _keyword = value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
Widget _content() {
|
||||
// 搜索优先:输了关键词就不看分类了
|
||||
if (_keyword.isNotEmpty) {
|
||||
return _searchResults();
|
||||
}
|
||||
if (_category != null) {
|
||||
return _categoryFoods(_category!);
|
||||
}
|
||||
return _categoryList();
|
||||
}
|
||||
|
||||
Widget _searchResults() {
|
||||
final async = ref.watch(foodSearchProvider(_keyword));
|
||||
|
||||
return switch (async) {
|
||||
AsyncValue(hasError: true) => const Center(
|
||||
child: Text('搜索失败',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) when value.isEmpty => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'没搜到。换个词试试,或者回分类里翻',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
AsyncValue(:final value?) => ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 20),
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, i) => _foodTile(value[i]),
|
||||
),
|
||||
_ => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _categoryList() {
|
||||
final async = ref.watch(foodCategoriesProvider);
|
||||
|
||||
return switch (async) {
|
||||
AsyncValue(hasError: true) => Center(
|
||||
child: Text('分类加载失败',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) => ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 20),
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, i) {
|
||||
final category = value[i];
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _category = category.name),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
category.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${category.count} 种',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right,
|
||||
size: 18, color: Color(0xFFD7CCC8)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_ => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _categoryFoods(String category) {
|
||||
final async = ref.watch(
|
||||
foodByCategoryProvider((category: category, subCategory: null)),
|
||||
);
|
||||
|
||||
return switch (async) {
|
||||
AsyncValue(hasError: true) => const Center(
|
||||
child: Text('加载失败',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) => ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 20),
|
||||
itemCount: value.length,
|
||||
itemBuilder: (_, i) => _foodTile(value[i]),
|
||||
),
|
||||
_ => const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFC8956E)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _foodTile(FoodItem food) {
|
||||
return GestureDetector(
|
||||
onTap: () => _pickPortion(food),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
food.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Color(0xFF5D4037)),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
// 食部只在 <100 时有意义(鸡蛋 88 = 壳占 12%),
|
||||
// 100 的写出来只是噪音
|
||||
_subtitle(food),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_fmt(food.energyKcal ?? 0),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
const Text('kcal/100g',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFFBCAAA4))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _subtitle(FoodItem food) {
|
||||
final parts = <String>[];
|
||||
if (food.subCategoryName.isNotEmpty) parts.add(food.subCategoryName);
|
||||
final edible = food.ediblePercent;
|
||||
if (edible != null && edible < 100) {
|
||||
parts.add('食部 ${_fmt(edible)}%');
|
||||
}
|
||||
return parts.isEmpty ? food.categoryName : parts.join(' · ');
|
||||
}
|
||||
|
||||
// ---------------- 选份量 ----------------
|
||||
|
||||
/// 选份量。这是「每 100g 的数据」变成「我吃了多少」的那一步。
|
||||
Future<void> _pickPortion(FoodItem food) async {
|
||||
final result = await showModalBottomSheet<_PortionResult>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _PortionSheet(food: food),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
try {
|
||||
await ref.read(healthApiProvider).addDiet(
|
||||
foodId: food.id,
|
||||
grams: result.grams,
|
||||
mealType: widget.mealType,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(dietTodayProvider);
|
||||
ref.invalidate(dietTodayTotalsProvider);
|
||||
showToast(context, '已添加到${mealLabel(widget.mealType)}',
|
||||
icon: 'check');
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PortionResult {
|
||||
const _PortionResult(this.grams);
|
||||
final double grams;
|
||||
}
|
||||
|
||||
/// 份量选择弹层:常用档位 + 手动改克数 + 实时热量预览
|
||||
class _PortionSheet extends StatefulWidget {
|
||||
const _PortionSheet({required this.food});
|
||||
|
||||
final FoodItem food;
|
||||
|
||||
@override
|
||||
State<_PortionSheet> createState() => _PortionSheetState();
|
||||
}
|
||||
|
||||
class _PortionSheetState extends State<_PortionSheet> {
|
||||
late final TextEditingController _ctrl;
|
||||
int? _selectedIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 默认选中第一个档位(就是「最常用」的那个,规则表里排第一)
|
||||
final portions = widget.food.portions;
|
||||
if (portions.isNotEmpty) {
|
||||
_selectedIndex = 0;
|
||||
_ctrl = TextEditingController(text: '${portions.first.grams}');
|
||||
} else {
|
||||
_ctrl = TextEditingController(text: '100');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _grams => double.tryParse(_ctrl.text.trim()) ?? 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final food = widget.food;
|
||||
final kcal = food.kcalFor(_grams);
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF7F4F1),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
food.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'每 100g ${_fmt(food.energyKcal ?? 0)} kcal'
|
||||
'${food.ediblePercent != null && food.ediblePercent! < 100 ? ' · 食部 ${_fmt(food.ediblePercent!)}%' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 常用档位
|
||||
if (food.portions.isNotEmpty) ...[
|
||||
const Text('常用份量',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < food.portions.length; i++)
|
||||
_portionChip(food.portions[i], i),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// 手动克数
|
||||
const Text('或者直接填克数',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true),
|
||||
onChanged: (_) => setState(() => _selectedIndex = null),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
suffixText: 'g',
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 实时热量:改克数时立刻能看到结果,不用等提交
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_fmt(kcal),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
const Text('kcal',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: Color(0xFFBCAAA4))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _grams <= 0
|
||||
? null
|
||||
: () => Navigator.of(context)
|
||||
.pop(_PortionResult(_grams)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC8956E),
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: const Color(0xFFE0D5CC),
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_grams <= 0 ? '请输入份量' : '添加 ${_fmt(_grams)}g',
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _portionChip(FoodPortion portion, int index) {
|
||||
final selected = _selectedIndex == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_selectedIndex = index;
|
||||
_ctrl.text = '${portion.grams}';
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFFC8956E) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? const Color(0xFFC8956E)
|
||||
: const Color(0xFFE0D5CC),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${portion.label} ${portion.grams}g',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: selected ? Colors.white : const Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
|
||||
String _fmt(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/health.dart';
|
||||
import '../../providers/health_provider.dart';
|
||||
import 'diet_page.dart';
|
||||
import 'goal_setup_page.dart';
|
||||
import 'weight_page.dart';
|
||||
import 'widgets/health_card.dart';
|
||||
|
||||
/// 「强宝健康」模块入口页。
|
||||
///
|
||||
/// 三个卡片就是三件事,按用户关心的顺序排:
|
||||
/// ① 减肥进度(目标层面:还差多少、节奏对不对)
|
||||
/// ② 当前体重(日常层面:今天称了没、趋势如何)
|
||||
/// ③ 今日饮食(执行层面:吃了多少)
|
||||
class HealthHomePage extends ConsumerWidget {
|
||||
const HealthHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: const Text('强宝健康'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: const Color(0xFFC8956E),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(weightGoalProvider);
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(dietTodayProvider);
|
||||
// 等目标那一路拉完再收起转圈 —— 它是三个卡片里最先渲染的
|
||||
try {
|
||||
await ref.read(weightGoalProvider.future);
|
||||
} catch (_) {
|
||||
// 失败就失败,RefreshIndicator 该收起来
|
||||
}
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
|
||||
children: const [
|
||||
_GoalCard(),
|
||||
SizedBox(height: 12),
|
||||
_WeightCard(),
|
||||
SizedBox(height: 12),
|
||||
_DietCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// ① 减肥进度
|
||||
// ==================================================================
|
||||
|
||||
class _GoalCard extends ConsumerWidget {
|
||||
const _GoalCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(weightGoalProvider);
|
||||
final goal = async.value;
|
||||
|
||||
// 加载中和出错都退化成「还没设目标」的形态 —— 卡片不该变成一块错误提示,
|
||||
// 用户想做的只是点进去设个目标
|
||||
if (goal == null || !goal.hasGoal) {
|
||||
return HealthCard(
|
||||
title: '减肥进度',
|
||||
onTap: () => _openGoal(context, ref),
|
||||
child: async.isLoading
|
||||
? const _CardLoading()
|
||||
: const _GoalEmpty(),
|
||||
);
|
||||
}
|
||||
|
||||
final current = goal.currentWeight;
|
||||
final lost = goal.lostWeight;
|
||||
final remain = goal.remainWeight;
|
||||
|
||||
return HealthCard(
|
||||
title: '减肥进度',
|
||||
onTap: () => _openGoal(context, ref),
|
||||
trailing: Text(
|
||||
'目标 ${_num(goal.targetWeight)} kg',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
current == null ? '--' : _num(current),
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Text('kg',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(width: 8),
|
||||
// Expanded + 右对齐:小屏上「已减 x kg」会被压窄,
|
||||
// 但不能把左边的体重数字顶出去
|
||||
Expanded(
|
||||
child: Text(
|
||||
lost == null ? '' : '已减 ${_num(lost)} kg',
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF4FA98A),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
HealthProgressBar(value: goal.weightProgress),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
remain == null
|
||||
? '还没记体重,记一次就能看进度'
|
||||
: '还差 ${_num(remain)} kg',
|
||||
maxLines: 2,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 单独一行放「每周应减」,不和「还差」挤同一个方向
|
||||
Flexible(
|
||||
child: Text(
|
||||
'每周应减 ${_num(goal.weeklyLoss)} kg',
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
// 每周超过 1kg 通常是掉肌肉和水,标黄提示
|
||||
color: goal.weeklyLossUnhealthy
|
||||
? const Color(0xFFD9A93C)
|
||||
: const Color(0xFF8D6E63),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (goal.weeklyLossUnhealthy) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
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, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openGoal(BuildContext context, WidgetRef ref) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const GoalSetupPage()),
|
||||
);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
ref.invalidate(weightChartProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoalEmpty extends StatelessWidget {
|
||||
const _GoalEmpty();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'还没设目标\n设一个,就有每周该减多少了',
|
||||
style: TextStyle(
|
||||
fontSize: 13, height: 1.6, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC8956E).withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
'去设置',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// ② 当前体重
|
||||
// ==================================================================
|
||||
|
||||
class _WeightCard extends ConsumerWidget {
|
||||
const _WeightCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(weightChartProvider);
|
||||
final chart = async.value;
|
||||
|
||||
final delta = chart?.delta;
|
||||
|
||||
return HealthCard(
|
||||
title: '当前体重',
|
||||
onTap: () => _openWeight(context, ref),
|
||||
trailing: delta == null
|
||||
? null
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
delta > 0 ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 13,
|
||||
// 涨了用红、掉了用绿 —— 这个模块的语境就是「掉是好事」
|
||||
color: delta > 0
|
||||
? const Color(0xFFE0725C)
|
||||
: const Color(0xFF4FA98A),
|
||||
),
|
||||
Text(
|
||||
'${_num(delta.abs())} kg',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: delta > 0
|
||||
? const Color(0xFFE0725C)
|
||||
: const Color(0xFF4FA98A),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: async.isLoading
|
||||
? const _CardLoading()
|
||||
: chart == null || !chart.hasData
|
||||
? const _WeightEmpty()
|
||||
: _WeightBody(chart: chart),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openWeight(BuildContext context, WidgetRef ref) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const WeightPage()),
|
||||
);
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeightBody extends StatelessWidget {
|
||||
const _WeightBody({required this.chart});
|
||||
|
||||
final WeightChart chart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = chart.currentWeight;
|
||||
final points = chart.points;
|
||||
final last = points.isEmpty ? null : points.last;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
current == null ? '--' : _num(current),
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Text('kg',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(width: 8),
|
||||
if (last != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
'最近 ${_shortDate(last.date)}',
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 卡片里只给迷你趋势,看完整图表点进详情页
|
||||
_MiniTrend(points: points),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片内嵌的迷你折线(不引图表库:这里只要一条走势线,
|
||||
/// fl_chart 在这么小的尺寸里坐标轴反而占掉一半空间)
|
||||
class _MiniTrend extends StatelessWidget {
|
||||
const _MiniTrend({required this.points});
|
||||
|
||||
final List<WeightPoint> points;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (points.length < 2) {
|
||||
return const SizedBox(
|
||||
height: 46,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'再记一次就能看出趋势了',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: _MiniTrendPainter(
|
||||
values: points.map<double>((p) => p.weight).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniTrendPainter extends CustomPainter {
|
||||
_MiniTrendPainter({required this.values});
|
||||
|
||||
final List<double> values;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (values.length < 2) return;
|
||||
|
||||
var min = values.first;
|
||||
var max = values.first;
|
||||
for (final v in values) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
// 上下各留 20% 余量,否则线会贴着边框
|
||||
final span = (max - min).abs() < 0.1 ? 1.0 : (max - min);
|
||||
final pad = span * 0.2;
|
||||
|
||||
final dx = size.width / (values.length - 1);
|
||||
final path = Path();
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
// 体重掉下去应该往下走 —— Y 轴要翻转(屏幕坐标 y 向下增长)
|
||||
final ratio = (values[i] - (min - pad)) / (span + pad * 2);
|
||||
final y = size.height - ratio * size.height;
|
||||
final x = dx * i;
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = const Color(0xFFC8956E)
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round,
|
||||
);
|
||||
|
||||
// 末端点一个实心圆,提示「这里是现在」
|
||||
final lastRatio = (values.last - (min - pad)) / (span + pad * 2);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width, size.height - lastRatio * size.height),
|
||||
3.2,
|
||||
Paint()..color = const Color(0xFFC8956E),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MiniTrendPainter old) =>
|
||||
old.values != values;
|
||||
}
|
||||
|
||||
class _WeightEmpty extends StatelessWidget {
|
||||
const _WeightEmpty();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'还没记过体重\n点这里记一下,就能看趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 13, height: 1.6, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
Icon(Icons.add_circle_outline, size: 26, color: Color(0xFFC8956E)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// ③ 今日饮食
|
||||
// ==================================================================
|
||||
|
||||
class _DietCard extends ConsumerWidget {
|
||||
const _DietCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(dietTodayProvider);
|
||||
final day = async.value;
|
||||
|
||||
final kcal = day?.totals.energyKcal ?? 0;
|
||||
final count = day?.totals.itemCount ?? 0;
|
||||
|
||||
return HealthCard(
|
||||
title: '今日饮食',
|
||||
onTap: () => _openDiet(context, ref),
|
||||
trailing: count == 0
|
||||
? null
|
||||
: Text(
|
||||
'$count 条',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
action: GestureDetector(
|
||||
onTap: () => _openDiet(context, ref),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC8956E).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add, size: 16, color: Color(0xFFC8956E)),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'记一笔',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: async.isLoading
|
||||
? const _CardLoading()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
_num(kcal),
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
const Text('kcal',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: Color(0xFF8D6E63))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (day == null || count == 0)
|
||||
const Text(
|
||||
'今天还没记,点这里加一条',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
||||
)
|
||||
else
|
||||
// 只列有内容的餐次,空餐次不占位
|
||||
...day.meals
|
||||
.where((m) => m.items.isNotEmpty)
|
||||
.map((m) => _MealLine(group: m)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openDiet(BuildContext context, WidgetRef ref) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const DietPage()),
|
||||
);
|
||||
ref.invalidate(dietTodayProvider);
|
||||
}
|
||||
}
|
||||
|
||||
class _MealLine extends StatelessWidget {
|
||||
const _MealLine({required this.group});
|
||||
|
||||
final MealGroup group;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final names = group.items
|
||||
.map((e) => '${e.foodName}(${_num(e.amountGrams)}g)')
|
||||
.join('、');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0EAE4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_mealLabel(group.mealType),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
names,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, height: 1.5, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_num(group.totals.energyKcal)} kcal',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Color(0xFF8D6E63),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
|
||||
class _CardLoading extends StatelessWidget {
|
||||
const _CardLoading();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Color(0xFFC8956E)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 数字去掉多余的小数位:65.0 → 65,65.5 → 65.5
|
||||
String _num(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
String _shortDate(DateTime date) {
|
||||
final m = date.month.toString().padLeft(2, '0');
|
||||
final d = date.day.toString().padLeft(2, '0');
|
||||
return '$m-$d';
|
||||
}
|
||||
|
||||
String _mealLabel(String mealType) {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '早';
|
||||
case 'lunch':
|
||||
return '午';
|
||||
case 'dinner':
|
||||
return '晚';
|
||||
default:
|
||||
return '加';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
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 'widgets/weight_line_chart.dart';
|
||||
|
||||
/// 体重页面:记体重 + 看折线图 + 看历史。
|
||||
///
|
||||
/// 顶部一个「记一下」输入区放着不动,下面依次是图表和历史 ——
|
||||
/// 这个页面最高频的动作就是「记一次」,它必须在第一屏、不用滚动就能点到。
|
||||
class WeightPage extends ConsumerStatefulWidget {
|
||||
const WeightPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WeightPage> createState() => _WeightPageState();
|
||||
}
|
||||
|
||||
class _WeightPageState extends ConsumerState<WeightPage> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _noteCtrl = TextEditingController();
|
||||
|
||||
bool _saving = false;
|
||||
DateTime _date = DateTime.now();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 预填最近一次体重 —— 大部分时候体重变化很小,改一下比重新敲快
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
final chart = await ref.read(healthApiProvider).weightChart();
|
||||
if (!mounted) return;
|
||||
final current = chart.currentWeight;
|
||||
if (current != null) {
|
||||
setState(() => _ctrl.text = _fmt(current));
|
||||
}
|
||||
} catch (_) {
|
||||
// 拿不到就留空手填
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
_noteCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chartAsync = ref.watch(weightChartProvider);
|
||||
final goalAsync = ref.watch(weightGoalProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
appBar: AppBar(
|
||||
title: const Text('体重记录'),
|
||||
backgroundColor: const Color(0xFFF7F4F1),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: const Color(0xFF5D4037),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 24),
|
||||
children: [
|
||||
_inputCard(),
|
||||
const SizedBox(height: 12),
|
||||
_chartCard(chartAsync, goalAsync.value),
|
||||
const SizedBox(height: 12),
|
||||
_historyCard(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 输入区 ----------------
|
||||
|
||||
Widget _inputCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('记录体重',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
)),
|
||||
const Spacer(),
|
||||
// 日期默认今天,要补记以前的就点这里改
|
||||
GestureDetector(
|
||||
onTap: _pickDate,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_isToday(_date) ? '今天' : _dateText(_date),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, color: Color(0xFFC8956E)),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
size: 16, color: Color(0xFFC8956E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '65.0',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFEFE5DC)),
|
||||
suffixText: 'kg',
|
||||
suffixStyle:
|
||||
TextStyle(fontSize: 14, color: Color(0xFF8D6E63)),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 8),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFC8956E),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 22, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
),
|
||||
child: Text(_saving ? '…' : '记录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _noteCtrl,
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF5D4037)),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '备注(可选),比如「早上空腹」',
|
||||
hintStyle: TextStyle(fontSize: 12.5, color: Color(0xFFBCAAA4)),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (picked != null) setState(() => _date = picked);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final weight = double.tryParse(_ctrl.text.trim());
|
||||
if (weight == null) {
|
||||
showToast(context, '请输入体重', error: true);
|
||||
return;
|
||||
}
|
||||
if (weight < 20 || weight > 300) {
|
||||
showToast(context, '体重需在 20 ~ 300 kg 之间', error: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(healthApiProvider).saveWeight(
|
||||
weight: weight,
|
||||
date: _date,
|
||||
note: _noteCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: _noteCtrl.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(weightHistoryProvider);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _noteCtrl.clear());
|
||||
showToast(context, '已记录', icon: 'check');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showToast(context, e.toString(), error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 图表 ----------------
|
||||
|
||||
Widget _chartCard(AsyncValue<WeightChart> chartAsync, WeightGoal? goal) {
|
||||
final chart = chartAsync.value;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 14, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
child: Text('体重变化',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
)),
|
||||
),
|
||||
if (chart != null && chart.hasData && goal != null && goal.hasGoal)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6, top: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
_legend(const Color(0xFFC8956E), '实测'),
|
||||
const SizedBox(width: 14),
|
||||
_legend(const Color(0xFF4FA98A), '理想'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (chartAsync.isLoading)
|
||||
const SizedBox(height: 220, child: LoadingView(tip: ''))
|
||||
else if (chartAsync.hasError)
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: Center(
|
||||
child: ErrorView(
|
||||
message: '图表加载失败',
|
||||
onRetry: () => ref.invalidate(weightChartProvider),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
WeightLineChart(
|
||||
points: chart?.points ?? const [],
|
||||
idealCurve: goal?.idealCurve ?? const [],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legend(Color color, String label) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 14,
|
||||
height: 2.5,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFF8D6E63))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 历史 ----------------
|
||||
|
||||
Widget _historyCard() {
|
||||
final async = ref.watch(weightHistoryProvider);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
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: 8),
|
||||
switch (async) {
|
||||
AsyncValue(hasError: true) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text('加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) when value.isEmpty => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text('还没有记录',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: Color(0xFFBCAAA4))),
|
||||
),
|
||||
AsyncValue(:final value?) => _historyList(value),
|
||||
_ => const SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Color(0xFFC8956E)),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _historyList(List<WeightRecordItem> items) {
|
||||
// 相邻两条的差值,让「掉了多少」一眼可见
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
_historyRow(items, i),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _historyRow(List<WeightRecordItem> items, int index) {
|
||||
final item = items[index];
|
||||
// 历史是倒序的,所以「上一次」是后面那条
|
||||
final previous = index + 1 < items.length ? items[index + 1] : null;
|
||||
final diff = previous == null ? null : item.weight - previous.weight;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 74,
|
||||
child: Text(
|
||||
item.date == null ? '--' : _dateShort(item.date!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${_fmt(item.weight)} kg',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
if (diff != null && diff.abs() >= 0.05) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
// 掉了给绿色,涨了给红 —— 这个模块的语境界定了颜色方向
|
||||
'${diff > 0 ? '+' : ''}${diff.toStringAsFixed(1)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: diff > 0
|
||||
? const Color(0xFFE0725C)
|
||||
: const Color(0xFF4FA98A),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.note.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.note,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _confirmDelete(item),
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
color: const Color(0xFFD7CCC8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 30, minHeight: 30),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(WeightRecordItem item) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除这条记录?'),
|
||||
content: Text(
|
||||
'${item.date == null ? '' : _dateShort(item.date!)} · ${_fmt(item.weight)} kg'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFE53935)),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
try {
|
||||
await ref.read(healthApiProvider).deleteWeight(item.id);
|
||||
if (!mounted) return;
|
||||
ref.invalidate(weightHistoryProvider);
|
||||
ref.invalidate(weightChartProvider);
|
||||
ref.invalidate(weightGoalProvider);
|
||||
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);
|
||||
}
|
||||
|
||||
bool _isToday(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
return date.year == now.year &&
|
||||
date.month == now.month &&
|
||||
date.day == now.day;
|
||||
}
|
||||
|
||||
String _dateText(DateTime date) =>
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
|
||||
String _dateShort(DateTime date) =>
|
||||
'${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 强宝健康主页上的一个卡片外壳。
|
||||
///
|
||||
/// 三个卡片(减肥进度 / 当前体重 / 今日饮食)结构完全一样:
|
||||
/// 标题 + 右上角可选的附属信息 + 内容区 + 底部按钮。抽出来是为了
|
||||
/// **高度和间距自动一致** —— 各自手写一遍迟早会差几个像素。
|
||||
class HealthCard extends StatelessWidget {
|
||||
const HealthCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// 右上角的小字,如「已减 1.8kg」
|
||||
final Widget? trailing;
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// 点卡片整体的行为(一般是进详情页)
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 底部按钮(如「+ 记一笔」)。为空时不占高度。
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF5D4037).withValues(alpha: 0.07),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null)
|
||||
// Flexible:右侧小字是「锦上添花」,空间不够时它该先让位,
|
||||
// 而不是把标题挤到溢出
|
||||
Flexible(child: trailing!),
|
||||
if (onTap != null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 2),
|
||||
child: Icon(Icons.chevron_right,
|
||||
size: 18, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条横向的进度条(减肥进度 / 时间进度共用)
|
||||
///
|
||||
/// [value] 会被夹到 0~1:后端虽然已经 Clamp 过,但这里再兜一次,
|
||||
/// 免得某天后端改了什么导致宽度溢出。
|
||||
class HealthProgressBar extends StatelessWidget {
|
||||
const HealthProgressBar({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.color = const Color(0xFFC8956E),
|
||||
this.height = 8,
|
||||
this.background = const Color(0xFFF0EAE4),
|
||||
});
|
||||
|
||||
final double value;
|
||||
final Color color;
|
||||
final double height;
|
||||
final Color background;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final safe = value.isFinite ? value.clamp(0.0, 1.0) : 0.0;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(height / 2),
|
||||
child: LinearProgressIndicator(
|
||||
value: safe,
|
||||
minHeight: height,
|
||||
backgroundColor: background,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个「标签 + 数值」的小块,卡片里成排用
|
||||
class HealthStat extends StatelessWidget {
|
||||
const HealthStat({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.unit,
|
||||
this.valueColor = const Color(0xFF5D4037),
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String? unit;
|
||||
final Color valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
if (unit != null) ...[
|
||||
const SizedBox(width: 2),
|
||||
Text(unit!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5, color: Color(0xFF8D6E63))),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/health_provider.dart';
|
||||
|
||||
/// 首页仪表盘上「强宝健康」卡片的摘要:当前体重 + 今日热量。
|
||||
///
|
||||
/// 只是两个数字,所以单独取 —— 不复用主页那套 provider(那些会拉明细),
|
||||
/// 为了一行字把整天饮食记录取回来是不划算的。
|
||||
class HealthModuleSummary extends ConsumerWidget {
|
||||
const HealthModuleSummary({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final goal = ref.watch(weightGoalProvider).value;
|
||||
final totals = ref.watch(dietTodayTotalsProvider).value;
|
||||
|
||||
final weight = goal?.currentWeight;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _item(
|
||||
'当前体重',
|
||||
weight == null ? null : '${_fmt(weight)} kg',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _item(
|
||||
'今日热量',
|
||||
totals == null || totals.itemCount == 0
|
||||
? null
|
||||
: '${_fmt(totals.energyKcal)} kcal',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _item(String label, String? value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value ?? '--',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _fmt(num value) {
|
||||
if (value == value.roundToDouble()) return value.round().toString();
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/health.dart';
|
||||
|
||||
/// 体重折线图(含理想目标线)。
|
||||
///
|
||||
/// 和 `donut_chart.dart` 一个纪律:**fl_chart 的类型只出现在这个文件里**。
|
||||
/// 页面只传数据进来,升级图表库时只需要改这一处。
|
||||
///
|
||||
/// 两条线:
|
||||
/// · 实测体重(实线,圆点)
|
||||
/// · 理想体重(虚线,来自目标)—— 一眼看出「落后了还是超前了」
|
||||
class WeightLineChart extends StatelessWidget {
|
||||
const WeightLineChart({
|
||||
super.key,
|
||||
required this.points,
|
||||
this.idealCurve = const [],
|
||||
this.height = 220,
|
||||
});
|
||||
|
||||
/// 实测体重点(按日期升序)
|
||||
final List<WeightPoint> points;
|
||||
|
||||
/// 理想体重曲线,为空就不画
|
||||
final List<WeightPoint> idealCurve;
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (points.isEmpty) {
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'还没有体重记录',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// X 轴用「距首日天数」而不是时间戳:时间戳在 fl_chart 里是毫秒级的大数,
|
||||
// 坐标轴标签会很难格式化,换算成天数后间隔和标签都很直观。
|
||||
final base = points.first.date;
|
||||
double dayOf(DateTime d) => d.difference(base).inDays.toDouble();
|
||||
|
||||
final actual = points
|
||||
.map((p) => FlSpot(dayOf(p.date), p.weight))
|
||||
.toList(growable: false);
|
||||
|
||||
// 理想曲线的起点可能早于实测(比如先设目标后开始记体重),
|
||||
// 所以基准日要取两者更早的那个,否则理想线会被挤到左边界外
|
||||
final ideal = <FlSpot>[];
|
||||
for (final p in idealCurve) {
|
||||
final x = dayOf(p.date);
|
||||
// 只画在实测数据的可见区间内,避免 X 轴被拉得过长
|
||||
if (x >= 0 && x <= dayOf(points.last.date)) {
|
||||
ideal.add(FlSpot(x, p.weight));
|
||||
}
|
||||
}
|
||||
|
||||
final (minY, maxY) = _yRange(actual, ideal);
|
||||
final maxX = dayOf(points.last.date);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: maxX <= 0 ? 1 : maxX,
|
||||
minY: minY,
|
||||
maxY: maxY,
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: ((maxY - minY) / 4).clamp(0.5, 100),
|
||||
getDrawingHorizontalLine: (_) => const FlLine(
|
||||
color: Color(0xFFF0EAE4),
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(),
|
||||
rightTitles: const AxisTitles(),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: ((maxY - minY) / 4).clamp(0.5, 100),
|
||||
getTitlesWidget: (value, meta) => Text(
|
||||
value.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 26,
|
||||
// 最多标 4 个日期,再多会挤成一团
|
||||
interval: maxX <= 0 ? 1 : (maxX / 3).ceilToDouble(),
|
||||
getTitlesWidget: (value, meta) {
|
||||
final date = base.add(Duration(days: value.round()));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'${date.month}-${date.day}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipColor: (_) => const Color(0xFF5D4037),
|
||||
getTooltipItems: (spots) => spots.map((spot) {
|
||||
final date = base.add(Duration(days: spot.x.round()));
|
||||
return LineTooltipItem(
|
||||
'${date.month}月${date.day}日\n${spot.y.toStringAsFixed(1)} kg',
|
||||
const TextStyle(
|
||||
color: Colors.white, fontSize: 12, height: 1.5),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
// 实测:实线 + 圆点
|
||||
LineChartBarData(
|
||||
spots: actual,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.25,
|
||||
preventCurveOverShooting: true,
|
||||
color: const Color(0xFFC8956E),
|
||||
barWidth: 2.6,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
// 点多了就不画圆点,否则整条线会变成一串珠子
|
||||
show: actual.length <= 20,
|
||||
getDotPainter: (spot, percent, bar, index) =>
|
||||
FlDotCirclePainter(
|
||||
radius: 3,
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
strokeColor: const Color(0xFFC8956E),
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: const Color(0xFFC8956E).withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
// 理想:虚线、不带点 —— 视觉上明确区分「这是参照不是实测」
|
||||
if (ideal.length >= 2)
|
||||
LineChartBarData(
|
||||
spots: ideal,
|
||||
isCurved: false,
|
||||
color: const Color(0xFF4FA98A),
|
||||
barWidth: 1.6,
|
||||
dashArray: const [5, 4],
|
||||
dotData: const FlDotData(show: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Y 轴范围:把实测点和理想线都包进来,上下各留 10% 余量。
|
||||
///
|
||||
/// 余量是必要的 —— 贴着边框的线看不出趋势,而且圆点会被裁掉半个。
|
||||
(double, double) _yRange(List<FlSpot> actual, List<FlSpot> ideal) {
|
||||
final values = <double>[
|
||||
...actual.map((s) => s.y),
|
||||
...ideal.map((s) => s.y),
|
||||
];
|
||||
if (values.isEmpty) return (0, 100);
|
||||
|
||||
var min = values.first;
|
||||
var max = values.first;
|
||||
for (final v in values) {
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
|
||||
// 所有点都一样重(用户连着记了几天同一个数)时给一个固定跨度,
|
||||
// 否则 maxY == minY 会让图表算出 NaN
|
||||
if ((max - min).abs() < 0.1) {
|
||||
return (min - 1, max + 1);
|
||||
}
|
||||
final pad = (max - min) * 0.1;
|
||||
return (min - pad, max + pad);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user