import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/app_toast.dart'; import '../../widgets/common_widgets.dart'; import '../../widgets/app_empty_state.dart'; import 'diet_nutrition_widgets.dart'; import 'diet_record_logic.dart'; final dietProvider = NotifierProvider( DietNotifier.new, ); class DietState { final String? imagePath; final List foods; final String mealType; final bool isAnalyzing; final int? healthScore; final String? errorMessage; final String? commentary; DietState({ this.imagePath, this.foods = const [], this.mealType = '', this.isAnalyzing = false, this.healthScore, this.errorMessage, this.commentary, }); DietState copyWith({ String? imagePath, List? foods, String? mealType, bool? isAnalyzing, int? healthScore, String? errorMessage, String? commentary, }) { return DietState( imagePath: imagePath ?? this.imagePath, foods: foods ?? this.foods, mealType: mealType ?? this.mealType, isAnalyzing: isAnalyzing ?? this.isAnalyzing, healthScore: healthScore ?? this.healthScore, errorMessage: errorMessage ?? this.errorMessage, commentary: commentary ?? this.commentary, ); } } class FoodItem { final String id; String name; String portion; int calories; bool selected; FoodItem({ required this.id, required this.name, required this.portion, required this.calories, this.selected = true, }); } class DietFoodValidationException implements Exception { final String message; const DietFoodValidationException(this.message); } class DietNotifier extends Notifier { @override DietState build() => DietState(); void setImage(String path) { state = state.copyWith(imagePath: path); } Future analyzeImage() async { state = DietState( imagePath: state.imagePath, mealType: state.mealType, isAnalyzing: true, ); try { final api = ref.read(apiClientProvider); final path = state.imagePath!; final imageFile = File(path); final formData = FormData.fromMap({ 'images': await MultipartFile.fromFile( imageFile.path, filename: imageFile.path.split('/').last, ), }); final res = await api.dio.post( '/api/ai/analyze-food-image', data: formData, ); final data = res.data; if (data['code'] != 0) { state = state.copyWith( isAnalyzing: false, errorMessage: data['message'] ?? '识别失败', ); return; } final raw = data['data'] as String? ?? '[]'; final foods = _parseFoodItems(raw); state = state.copyWith( foods: foods, isAnalyzing: false, healthScore: foods.isNotEmpty ? 3 : null, ); if (foods.isNotEmpty) _fetchCommentary(foods); } catch (e) { state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试'); } } Future _fetchCommentary(List foods) async { try { final api = ref.read(apiClientProvider); final response = await api.post( '/api/ai/diet-commentary', data: { 'foods': foods .map( (food) => { 'name': food.name, 'portion': food.portion, 'calories': food.calories, }, ) .toList(), }, ); final text = response.data['data']?['commentary']?.toString().trim() ?? ''; if (text.isNotEmpty) state = state.copyWith(commentary: text); } catch (e) { debugPrint('[Diet] 获取饮食点评失败: $e'); } } List _parseFoodItems(String raw) { var json = raw.trim(); if (json.startsWith('```')) { final start = json.indexOf('\n'); if (start != -1) json = json.substring(start + 1); final end = json.lastIndexOf('```'); if (end != -1) json = json.substring(0, end); json = json.trim(); } try { final list = jsonDecode(json) as List; return list.asMap().entries.map((e) { final item = e.value as Map; return FoodItem( id: 'food_${DateTime.now().millisecondsSinceEpoch}_${e.key}', name: item['name']?.toString() ?? '未知食物', portion: item['portion']?.toString() ?? '', calories: (item['calories'] as num?)?.toInt() ?? 0, selected: true, ); }).toList(); } catch (_) { return [ FoodItem( id: 'food_${DateTime.now().millisecondsSinceEpoch}', name: '识别结果(手动编辑)', portion: raw.length > 50 ? raw.substring(0, 50) : raw, calories: 0, selected: true, ), ]; } } void updateFoodName(String id, String name) { final foods = state.foods .map( (f) => f.id == id ? FoodItem( id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected, ) : f, ) .toList(); state = state.copyWith(foods: foods); } void updateFoodPortion(String id, String portion) { final foods = state.foods .map( (f) => f.id == id ? FoodItem( id: f.id, name: f.name, portion: portion, calories: f.calories, selected: f.selected, ) : f, ) .toList(); state = state.copyWith(foods: foods); } void updateFoodCalories(String id, int calories) { final foods = state.foods .map( (f) => f.id == id ? FoodItem( id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected, ) : f, ) .toList(); state = state.copyWith(foods: foods); } void toggleFood(String id) { final foods = state.foods .map( (f) => f.id == id ? FoodItem( id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected, ) : f, ) .toList(); state = state.copyWith(foods: foods); } void addFood() { final newId = '${DateTime.now().millisecondsSinceEpoch}'; state = state.copyWith( foods: [ ...state.foods, FoodItem(id: newId, name: '', portion: '', calories: 0), ], ); } void setMealType(String type) { state = state.copyWith(mealType: type); } void reset() { state = DietState(); } Future saveRecord() async { if (state.mealType.isEmpty) { throw const DietFoodValidationException('请选择餐次'); } final selectedFoods = state.foods.where((f) => f.selected).toList(); if (selectedFoods.isEmpty) { throw const DietFoodValidationException('请至少选择一种食物'); } final hasIncompleteFood = selectedFoods.any( (food) => !isSavableFood( name: food.name, portion: food.portion, calories: food.calories, ), ); if (hasIncompleteFood) { throw const DietFoodValidationException('请完善已选食物的名称、份量和热量'); } final api = ref.read(apiClientProvider); final mealMap = { 'breakfast': 'Breakfast', 'lunch': 'Lunch', 'dinner': 'Dinner', 'snack': 'Snack', }; final totalCal = selectedFoods.fold(0, (s, f) => s + f.calories); await api.post( '/api/diet-records', data: { 'mealType': mealMap[state.mealType]!, 'totalCalories': totalCal, 'healthScore': state.healthScore ?? 3, 'recordedAt': DateTime.now().toIso8601String().substring(0, 10), 'foodItems': selectedFoods .asMap() .entries .map( (e) => { 'name': e.value.name, 'portion': e.value.portion, 'calories': e.value.calories, 'sortOrder': e.key, }, ) .toList(), }, ); } } // 饮食主题色 const _dietAccent = DietPalette.primary; const _dietKcalText = DietPalette.calorie; class DietCapturePage extends ConsumerStatefulWidget { const DietCapturePage({super.key}); @override ConsumerState createState() => _DietCapturePageState(); } class _DietCapturePageState extends ConsumerState { @override void initState() { super.initState(); } @override void dispose() { for (final ctrl in _fieldCtrls.values) { ctrl.dispose(); } _fieldCtrls.clear(); super.dispose(); } @override Widget build(BuildContext context) { final state = ref.watch(dietProvider); return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white, title: const Text( '饮食分析', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), ), centerTitle: true, ), body: state.imagePath == null ? const Center( child: Text( '请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary, fontSize: 16), ), ) : _buildResultView(context, ref), bottomNavigationBar: !state.isAnalyzing && state.foods.isNotEmpty ? _buildBottomActions() : null, ); } Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildPhotoPreview(state), const SizedBox(height: 14), if (state.isAnalyzing) _buildAnalyzing(state) else if (state.foods.isEmpty) _buildNoFoodHint() else ...[ _buildMealSummary(ref), const SizedBox(height: 14), _buildFoodList(ref), const SizedBox(height: 14), _buildAiCommentary(state.commentary), ], ], ), ); } Widget _buildPhotoPreview(DietState state) { return ClipRRect( borderRadius: AppRadius.mdBorder, child: Image.file( File(state.imagePath!), width: double.infinity, height: 190, fit: BoxFit.cover, ), ); } Widget _buildMealSummary(WidgetRef ref) { final state = ref.watch(dietProvider); final totalCal = state.foods .where((f) => f.selected) .fold(0, (s, f) => s + f.calories); final protein = (totalCal * 0.16 / 4).round(); final carbs = (totalCal * 0.52 / 4).round(); final fat = (totalCal * 0.28 / 9).round(); return Container( padding: const EdgeInsets.fromLTRB(16, 14, 16, 15), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text('本餐估算', style: AppTextStyles.sectionTitle), const Spacer(), _MealTypeSelector( value: state.mealType, onChanged: (type) => ref.read(dietProvider.notifier).setMealType(type), ), ], ), const SizedBox(height: 20), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ DietCalorieRing(calories: totalCal), const SizedBox(width: 20), Expanded( child: Column( children: [ DietMacroBar( label: '蛋白质', value: protein, color: DietPalette.protein, reference: 50, ), const SizedBox(height: 14), DietMacroBar( label: '碳水', value: carbs, color: DietPalette.carbs, reference: 100, ), const SizedBox(height: 14), DietMacroBar( label: '脂肪', value: fat, color: DietPalette.fat, reference: 35, ), ], ), ), ], ), ], ), ); } // 分析中 Widget _buildAnalyzing(DietState state) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Column( children: [ const SizedBox( width: 48, height: 48, child: CircularProgressIndicator( strokeWidth: 3, color: _dietAccent, ), ), const SizedBox(height: 16), const Text( '正在识别食物...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary), ), if (state.errorMessage != null) ...[ const SizedBox(height: 8), Text( state.errorMessage!, style: const TextStyle(fontSize: 14, color: AppColors.errorText), textAlign: TextAlign.center, ), ], ], ), ); } Widget _buildNoFoodHint() { return AppEmptyState( icon: Icons.image_not_supported_outlined, iconColor: _dietAccent, title: '未识别到食物', subtitle: '请重新拍摄或选择含有食物的清晰照片', ); } // 食物列表 Widget _buildFoodList(WidgetRef ref) { final state = ref.watch(dietProvider); return Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.fromLTRB(14, 13, 14, 8), child: Text( '识别结果', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ), for (var i = 0; i < state.foods.length; i++) _foodItemTile( ref, state.foods[i], showDivider: i < state.foods.length - 1, ), Align( alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: () => ref.read(dietProvider.notifier).addFood(), icon: const Icon( Icons.add_circle_outline, size: 20, color: _dietAccent, ), label: const Text( '添加食物', style: TextStyle(fontSize: 15, color: _dietAccent), ), ), ), ], ), ); } Widget _foodItemTile( WidgetRef ref, FoodItem food, { required bool showDivider, }) { return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 14, 12, 14), child: Row( children: [ GestureDetector( onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id), child: Container( width: 26, height: 26, alignment: Alignment.center, decoration: BoxDecoration( color: food.selected ? AppColors.primary : AppColors.primarySoft, borderRadius: AppRadius.smBorder, border: Border.all( color: food.selected ? Colors.transparent : const Color(0xFFC9D0FF), ), ), child: food.selected ? const Icon(Icons.check, size: 15, color: Colors.white) : null, ), ), const SizedBox(width: 12), Expanded( flex: 4, child: _plainField( food.name, (v) => ref .read(dietProvider.notifier) .updateFoodName(food.id, v), fieldKey: '${food.id}_name', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ), const SizedBox(width: 8), Expanded( flex: 3, child: _plainField( food.portion, (v) => ref .read(dietProvider.notifier) .updateFoodPortion(food.id, v), fieldKey: '${food.id}_portion', hint: '份量', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), ), const SizedBox(width: 8), SizedBox( width: 96, child: Row( children: [ Expanded( child: _plainField( food.calories.toString(), (v) => ref .read(dietProvider.notifier) .updateFoodCalories(food.id, int.tryParse(v) ?? 0), fieldKey: '${food.id}_cal', hint: '0', align: TextAlign.right, keyboardType: TextInputType.number, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: _dietKcalText, ), ), ), const Text( ' 千卡', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textHint, ), ), ], ), ), ], ), ), if (showDivider) const Padding( padding: EdgeInsets.only(left: 52), child: Divider(height: 1, thickness: 0.7, color: Color(0xFFE8ECF2)), ), ], ); } Widget _plainField( String value, ValueChanged cb, { required String fieldKey, String? hint, TextStyle? style, TextAlign align = TextAlign.start, TextInputType? keyboardType, }) { return TextField( controller: _ctrlFor(fieldKey, value), onChanged: cb, keyboardType: keyboardType, textAlign: align, style: style ?? const TextStyle(fontSize: 15), decoration: InputDecoration( filled: false, fillColor: Colors.transparent, isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, hintText: hint, hintStyle: const TextStyle(fontSize: 16, color: AppColors.textHint), ), ); } final Map _fieldCtrls = {}; TextEditingController _ctrlFor(String key, String value) { if (_fieldCtrls.containsKey(key)) return _fieldCtrls[key]!; final c = TextEditingController(text: value); _fieldCtrls[key] = c; return c; } // AI 点评 Widget _buildAiCommentary(String? text) { final hasText = text != null && text.trim().isNotEmpty; return Container( padding: const EdgeInsets.fromLTRB(14, 13, 14, 14), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 4, height: 18, decoration: BoxDecoration( color: _dietAccent, borderRadius: AppRadius.pillBorder, ), ), const SizedBox(width: 9), const Text( 'AI饮食建议', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ], ), const SizedBox(height: 12), Text( hasText ? text.trim() : 'AI饮食建议生成中', style: TextStyle( fontSize: 15, color: hasText ? AppColors.textPrimary : AppColors.textHint, height: 1.55, fontWeight: FontWeight.w600, ), ), ], ), ); } Widget _buildBottomActions() { return DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.96), boxShadow: [ BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.08), blurRadius: 16, offset: const Offset(0, -6), ), ], ), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), child: AppGradientOutlineButton( label: '保存记录', onPressed: _saveDietRecord, ), ), ), ); } Future _saveDietRecord() async { try { await ref.read(dietProvider.notifier).saveRecord(); if (mounted) popRoute(ref); } on DietFoodValidationException catch (e) { if (mounted) { AppToast.show(context, e.message, type: AppToastType.warning); } } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); if (mounted) { AppToast.show(context, '保存失败,请检查网络后重试', type: AppToastType.error); } } } } class _MealTypeSelector extends StatelessWidget { final String value; final ValueChanged onChanged; const _MealTypeSelector({required this.value, required this.onChanged}); static const _options = [ (label: '早餐', value: 'breakfast', icon: Icons.wb_sunny_outlined), (label: '午餐', value: 'lunch', icon: Icons.light_mode_outlined), (label: '晚餐', value: 'dinner', icon: Icons.nights_stay_outlined), (label: '加餐', value: 'snack', icon: Icons.cookie_outlined), ]; String get _label { for (final option in _options) { if (option.value == value) return option.label; } return '餐次'; } @override Widget build(BuildContext context) { final selected = value.isNotEmpty; return GestureDetector( onTap: () => _showOptions(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( color: selected ? DietPalette.primarySoft : Colors.white, borderRadius: AppRadius.mdBorder, border: Border.all( color: selected ? const Color(0xFFC9D0FF) : AppColors.border, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _label, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), const SizedBox(width: 5), const Icon( Icons.keyboard_arrow_down_rounded, size: 18, color: AppColors.textSecondary, ), ], ), ), ); } Future _showOptions(BuildContext context) async { final selected = await showModalBottomSheet( context: context, backgroundColor: Colors.white, showDragHandle: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), ), builder: (sheetContext) => SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('选择餐次', style: AppTextStyles.sectionTitle), const SizedBox(height: 12), for (var i = 0; i < _options.length; i++) ...[ InkWell( borderRadius: AppRadius.mdBorder, onTap: () => Navigator.pop(sheetContext, _options[i].value), child: Padding( padding: const EdgeInsets.symmetric(vertical: 13), child: Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: DietPalette.primarySoft, borderRadius: AppRadius.smBorder, ), child: Icon( _options[i].icon, size: 20, color: DietPalette.primary, ), ), const SizedBox(width: 12), Expanded( child: Text( _options[i].label, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ), if (_options[i].value == value) const Icon( Icons.check_rounded, size: 21, color: DietPalette.primary, ), ], ), ), ), if (i < _options.length - 1) const Padding( padding: EdgeInsets.only(left: 50), child: Divider(height: 1, color: AppColors.divider), ), ], ], ), ), ), ); if (selected != null) onChanged(selected); } }