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 createState() => _FoodPickerPageState(); } class _FoodPickerPageState extends ConsumerState { 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 = []; 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 _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); }