Files
Aq-Accounting-Flutter/lib/pages/health/food_picker_page.dart
T
2026-09-28 16:40:32 +08:00

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);
}