import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.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(0xFFF0A060); const _dietAccentLight = Color(0xFFFFF2E8); const _dietBg = Color(0xFFFFFBF7); 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 Scaffold( backgroundColor: _dietBg, 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), ); } Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); final screenW = MediaQuery.of(context).size.width; return Container( decoration: const BoxDecoration(gradient: LinearGradient(colors: [_dietBg, Color(0xFFFFF8F2)])), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column(children: [ // 图片自适应显示 ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.file( File(state.imagePath!), width: screenW - 32, 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(4), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), 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), padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( color: sel ? _dietAccentLight : Colors.white, borderRadius: BorderRadius.circular(12), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ Text(m.$1, style: const TextStyle(fontSize: 22)), const SizedBox(height: 2), Text(m.$2, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: sel ? _dietAccent : AppColors.textHint)), ]), ), ), ); }).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), 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(0xFFFFF3E0), borderRadius: BorderRadius.circular(8)), child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFE68A00))), ), ]), 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), 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), 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), hint: '0', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFE68A00)), align: TextAlign.right, keyboardType: TextInputType.number)), const SizedBox(width: 2), const Text('kcal', style: TextStyle(fontSize: 13, color: AppColors.textHint)), ])), ]), ]), ), GestureDetector( onTap: () => ref.read(dietProvider.notifier).removeFood(food.id), child: const Icon(Icons.close, size: 18, color: AppColors.textHint), ), ]), ); } Widget _compactField(String value, ValueChanged cb, {String? hint, TextStyle? style, TextAlign align = TextAlign.start, TextInputType? keyboardType}) { return TextField( controller: TextEditingController(text: value), onChanged: cb, keyboardType: keyboardType, textAlign: align, style: style ?? const TextStyle(fontSize: 15), decoration: InputDecoration(isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, 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: const Color(0xFFFFF8F0), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFFFE8D0)), ), child: 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: const Color(0xFFFFE8D0), 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: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('本餐热量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const SizedBox(height: 6), 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: const Color(0xFFFFE8C0)), ), child: 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 SizedBox( width: double.infinity, height: 52, child: ElevatedButton.icon( onPressed: () async { try { await ref.read(dietProvider.notifier).saveRecord(); if (mounted) popRoute(ref); } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); } }, icon: const Icon(Icons.check_circle_outline, size: 22), label: const Text('保存记录', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), style: ElevatedButton.styleFrom( backgroundColor: _dietAccent, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), ), ), ); } }