566 lines
18 KiB
Dart
566 lines
18 KiB
Dart
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);
|
|
}
|