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_theme.dart'; import '../../providers/auth_provider.dart'; import '../../utils/sse_handler.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 = 'lunch', 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 DietNotifier extends Notifier { @override DietState build() => DietState(); void setImage(String path) { state = state.copyWith(imagePath: path); } Future analyzeImage() async { state = state.copyWith(isAnalyzing: true, errorMessage: null); 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 token = await api.accessToken; if (token == null) return; final names = foods .map((f) => '${f.name}(${f.portion},${f.calories}kcal)') .join('、'); final stream = SseHandler.connect( agentType: 'default', message: '我刚才吃了这些:$names。请结合我的健康档案,给我简短的饮食评价和建议(50字以内)。', token: token, ); String text = ''; await for (final event in stream) { if (event['action'] == 'answer') text += (event['data'] as String?) ?? ''; if (event['action'] == 'status') { if (text.isNotEmpty) state = state.copyWith(commentary: text.trim()); } } } 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: 100), ], ); } void removeFood(String id) { state = state.copyWith( foods: state.foods.where((f) => f.id != id).toList(), ); } void setMealType(String type) { state = state.copyWith(mealType: type); } void reset() { state = DietState(); } Future saveRecord() async { final selectedFoods = state.foods.where((f) => f.selected).toList(); if (selectedFoods.isEmpty) return; 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] ?? 'Lunch', '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 = Color(0xFFF97316); const _dietAccentLight = Color(0xFFFFF3E0); const _dietKcalText = Color(0xFF9A3412); const _dietGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF6D365), Color(0xFFFDA085)], ); class DietCapturePage extends ConsumerStatefulWidget { const DietCapturePage({super.key}); @override ConsumerState createState() => _DietCapturePageState(); } class _DietCapturePageState extends ConsumerState { @override void initState() { super.initState(); } @override Widget build(BuildContext context) { final state = ref.watch(dietProvider); return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white.withValues(alpha: 0.9), 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), ); } Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); final screenW = MediaQuery.of(context).size.width; return Container( decoration: const BoxDecoration(gradient: AppColors.bgGradient), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( children: [ // 图片自适应显示 Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(24), border: Border.all(color: AppColors.borderLight), boxShadow: AppColors.cardShadowLight, ), child: ClipRRect( borderRadius: BorderRadius.circular(18), child: Image.file( File(state.imagePath!), width: screenW - 48, height: 220, fit: BoxFit.cover, ), ), ), const SizedBox(height: 16), _buildMealSelector(ref), const SizedBox(height: 16), if (state.isAnalyzing) _buildAnalyzing(state) else ...[ if (state.foods.isNotEmpty) ...[ _buildFoodList(ref), const SizedBox(height: 16), _buildNutritionCard(ref), if (state.commentary != null && state.commentary!.isNotEmpty) ...[ const SizedBox(height: 16), _buildAiCommentary(state.commentary!), ], ], const SizedBox(height: 20), _buildSaveButton(), ], ], ), ), ); } // ─────────── 餐次选择器 ─────────── Widget _buildMealSelector(WidgetRef ref) { final state = ref.watch(dietProvider); final meals = [ ('🌅', '早餐', 'breakfast'), ('☀️', '午餐', 'lunch'), ('🌙', '晚餐', 'dinner'), ('🍪', '加餐', 'snack'), ]; return Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white, width: 1.4), boxShadow: AppColors.cardShadowLight, ), child: Row( children: meals.map((m) { final sel = state.mealType == m.$3; return Expanded( child: GestureDetector( onTap: () => ref.read(dietProvider.notifier).setMealType(m.$3), child: AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( gradient: sel ? _dietGradient : null, color: sel ? null : const Color(0xFFFFFBF6), borderRadius: BorderRadius.circular(16), border: Border.all( color: sel ? Colors.transparent : const Color(0xFFFFE4CA), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox.shrink(), const SizedBox(height: 0), Text( m.$2, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w900, color: sel ? Colors.white : _dietKcalText, ), ), ], ), ), ), ); }).toList(), ), ); } // ─────────── 分析中 ─────────── 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.error), textAlign: TextAlign.center, ), ], ], ), ); } // ─────────── 食物列表 ─────────── Widget _buildFoodList(WidgetRef ref) { final state = ref.watch(dietProvider); final totalCal = state.foods .where((f) => f.selected) .fold(0, (s, f) => s + f.calories); return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 4, height: 16, decoration: BoxDecoration( color: _dietAccent, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(width: 8), const Text( '识别结果', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const Spacer(), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( color: const Color(0xFFFFF3D8), borderRadius: BorderRadius.circular(8), ), child: Text( '共 $totalCal kcal', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: _dietKcalText, ), ), ), ], ), const SizedBox(height: 10), ...state.foods.map((food) => _foodItemTile(ref, food)), const SizedBox(height: 4), Center( 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) { return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(12), border: Border.all( color: food.selected ? _dietAccent.withAlpha(60) : AppColors.borderLight, ), ), child: Row( children: [ GestureDetector( onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id), child: Container( width: 22, height: 22, decoration: BoxDecoration( shape: BoxShape.circle, color: food.selected ? _dietAccent : Colors.white, border: Border.all( color: food.selected ? _dietAccent : AppColors.border, width: 2, ), ), child: food.selected ? const Icon(Icons.check, size: 14, color: Colors.white) : null, ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _compactField( food.name, (v) => ref .read(dietProvider.notifier) .updateFoodName(food.id, v), fieldKey: '${food.id}_name', style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Row( children: [ Expanded( flex: 3, child: _compactField( food.portion, (v) => ref .read(dietProvider.notifier) .updateFoodPortion(food.id, v), fieldKey: '${food.id}_portion', hint: '份量', style: const TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), ), const SizedBox(width: 8), Expanded( flex: 2, child: Row( children: [ Expanded( child: _compactField( food.calories.toString(), (v) => ref .read(dietProvider.notifier) .updateFoodCalories( food.id, int.tryParse(v) ?? 0, ), fieldKey: '${food.id}_cal', hint: '0', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: _dietKcalText, ), align: TextAlign.right, keyboardType: TextInputType.number, ), ), const SizedBox(width: 2), const Text( 'kcal', style: TextStyle( fontSize: 13, color: Color(0xFFB45309), ), ), ], ), ), ], ), ], ), ), GestureDetector( onTap: () => ref.read(dietProvider.notifier).removeFood(food.id), child: const Icon(Icons.close, size: 18, 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; } Widget _compactField( 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( isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), filled: true, fillColor: Colors.white, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.borderLight), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.borderLight), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: _dietAccent), ), hintText: hint, hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint), ), ); } // ─────────── 营养摘要 ─────────── Widget _buildNutritionCard(WidgetRef ref) { final state = ref.watch(dietProvider); final totalCal = state.foods .where((f) => f.selected) .fold(0, (s, f) => s + f.calories); return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '本餐热量', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), const SizedBox(height: 14), Row( children: [ SizedBox( width: 56, height: 56, child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 56, height: 56, child: CircularProgressIndicator( value: (totalCal / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: AppColors.borderLight, color: _dietAccent, ), ), Column( mainAxisSize: MainAxisSize.min, children: [ Text( '$totalCal', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: _dietAccent, ), ), const Text( 'kcal', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), ], ), ), const SizedBox(width: 16), Expanded( child: Row( children: [ _macro('碳水', 0.55, const Color(0xFFF5A623)), const SizedBox(width: 8), _macro('蛋白', 0.25, const Color(0xFF4A90D9)), const SizedBox(width: 8), _macro('脂肪', 0.20, const Color(0xFFE8686A)), ], ), ), ], ), ], ), ); } Widget _macro(String label, double ratio, Color color) { return Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 4), Text( label, style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), const SizedBox(height: 3), ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: ratio, minHeight: 4, backgroundColor: AppColors.borderLight, color: color, ), ), ], ), ); } // ─────────── AI 点评 ─────────── Widget _buildAiCommentary(String text) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFFFFFBF5), borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'AI建议', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: _dietAccentLight, borderRadius: BorderRadius.circular(10), ), child: const Icon( Icons.auto_awesome, size: 20, color: _dietAccent, ), ), const SizedBox(width: 12), Expanded( child: Text( text, style: const TextStyle( fontSize: 16, color: AppColors.textPrimary, height: 1.6, ), ), ), ], ), ], ), ); } // ─────────── 保存按钮 ─────────── Widget _buildSaveButton() { return Container( width: double.infinity, height: 52, decoration: BoxDecoration( gradient: _dietGradient, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: _dietAccent.withValues(alpha: 0.24), blurRadius: 16, offset: const Offset(0, 8), ), ], ), child: Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: () async { try { await ref.read(dietProvider.notifier).saveRecord(); if (mounted) popRoute(ref); } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); } }, child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.check_circle_outline, size: 22, color: Colors.white), SizedBox(width: 8), Text( '保存记录', style: TextStyle( fontSize: 17, fontWeight: FontWeight.w800, color: Colors.white, ), ), ], ), ), ), ); } }