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