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!; debugPrint('[DietDebug] analyzeImage using: $path'); 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}'; final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)]; state = state.copyWith(foods: foods); } void removeFood(String id) { final foods = state.foods.where((f) => f.id != id).toList(); state = state.copyWith(foods: foods); } 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(), }); } } class DietCapturePage extends ConsumerStatefulWidget { const DietCapturePage({super.key}); @override ConsumerState createState() => _DietCapturePageState(); } class _DietCapturePageState extends ConsumerState { @override void initState() { super.initState(); // 不 reset — 图片由 home_page 传入,这里只做展示 } @override Widget build(BuildContext context) { final state = ref.watch(dietProvider); return Scaffold( appBar: AppBar( title: const Text('拍饮食'), centerTitle: true, ), body: state.imagePath == null ? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary))) : _buildResultView(context, ref), ); } // ─────────── 设计常量(与项目整体色调一致)─────────── static const _kPrimary = AppTheme.primary; // #6C5CE7 static const _kPrimaryLight = AppTheme.primaryLight; // #EDEAFF static const _kPageBg = AppTheme.bg; // #F8F9FC static const _kSurface = AppTheme.surface; // white static const _kText = AppTheme.text; // #2D2B32 static const _kSubText = AppTheme.textSub; // #8A8892 static const _kBorder = AppTheme.border; // #EAEAF0 static const _kWarning = AppTheme.warning; // #F5A623 Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories); return Container( color: AppColors.background, child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column(children: [ _buildImagePreview(state.imagePath!), const SizedBox(height: 16), _buildMealSelector(ref), const SizedBox(height: 16), if (state.isAnalyzing) _buildAnalyzingIndicator(state) else ...[ if (state.foods.isNotEmpty) ...[ _buildFoodList(ref), const SizedBox(height: 16), _buildNutritionSummary(totalCalories), if (state.commentary != null && state.commentary!.isNotEmpty) ...[ const SizedBox(height: 16), _buildAiCommentary(state.commentary!), ], ], const SizedBox(height: 20), _buildSubmitButton(), const SizedBox(height: 16), ], ]), ), ); } // ─────────── 图片预览 ─────────── Widget _buildImagePreview(String path) { return Center( child: ClipRRect( borderRadius: BorderRadius.circular(20), child: Container( height: 200, width: MediaQuery.of(context).size.width * 0.75, decoration: BoxDecoration( image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover), boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 14, offset: const Offset(0, 4))], ), foregroundDecoration: const BoxDecoration( gradient: LinearGradient( colors: [Colors.transparent, Colors.black38], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), ), ), ); } // ─────────── 餐次选择器 ─────────── Widget _buildMealSelector(WidgetRef ref) { final state = ref.watch(dietProvider); final meals = [ _MealData(Icons.wb_sunny_outlined, '早餐', 'breakfast'), _MealData(Icons.wb_cloudy_outlined, '午餐', 'lunch'), _MealData(Icons.nightlight_round, '晚餐', 'dinner'), _MealData(Icons.local_cafe_outlined, '加餐', 'snack'), ]; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: meals.map((m) { final isSelected = state.mealType == m.type; return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: GestureDetector( onTap: () => ref.read(dietProvider.notifier).setMealType(m.type), child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected ? _kPrimary : _kSurface, borderRadius: BorderRadius.circular(14), border: Border.all(color: isSelected ? _kPrimary : _kBorder), boxShadow: isSelected ? [BoxShadow(color: _kPrimary.withAlpha(30), blurRadius: 6, offset: const Offset(0, 2))] : null, ), child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(m.icon, size: 23, color: isSelected ? AppColors.textOnGradient : _kSubText), const SizedBox(height: 4), Text(m.label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isSelected ? AppColors.textOnGradient : _kSubText)), ]), ), ), ), ); }).toList(), ), ); } // ─────────── 分析中指示器 ─────────── Widget _buildAnalyzingIndicator(DietState state) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Center( child: Column(children: [ SizedBox( width: 56, height: 56, child: Stack( alignment: Alignment.center, children: [ const CircularProgressIndicator(strokeWidth: 3, color: _kPrimary), const Icon(Icons.restaurant, size: 25, color: _kPrimary), ], ), ), const SizedBox(height: 20), const Text('正在识别食物...', style: TextStyle(fontSize: 18, color: _kSubText)), if (state.errorMessage != null) ...[ const SizedBox(height: 8), Text(state.errorMessage!, style: const TextStyle(fontSize: 16, color: AppTheme.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 Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( decoration: BoxDecoration( color: _kSurface, borderRadius: BorderRadius.circular(16), boxShadow: [AppTheme.shadowLight], ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 14, 8, 0), child: Row(children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)), child: const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kPrimary)), ), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)), child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kWarning)), ), IconButton( icon: const Icon(Icons.add_circle_outline, size: 25, color: _kPrimary), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () => ref.read(dietProvider.notifier).addFood(), ), ]), ), const SizedBox(height: 8), ...state.foods.map((food) => _buildFoodItem(ref, food)), const SizedBox(height: 12), ]), ), ); } Widget _buildFoodItem(WidgetRef ref, FoodItem food) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: food.selected ? AppColors.iconBg : AppColors.background, borderRadius: BorderRadius.circular(AppTheme.rMd), border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : AppColors.border), ), child: Padding( padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), 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 ? _kPrimary : AppTheme.surface, border: Border.all(color: food.selected ? _kPrimary : AppColors.border, width: 2), ), child: food.selected ? const Icon(Icons.check, size: 17, color: AppColors.textOnGradient) : null, ), ), const SizedBox(width: 12), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: TextEditingController(text: food.name), onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _kText), decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, ), ), const SizedBox(height: 2), Row(children: [ Expanded( flex: 3, child: TextField( controller: TextEditingController(text: food.portion), onChanged: (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v), style: const TextStyle(fontSize: 15, color: _kSubText), decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, hintText: '份量', hintStyle: TextStyle(fontSize: 15, color: AppTheme.textHint), ), ), ), const SizedBox(width: 8), Expanded( flex: 2, child: TextField( controller: TextEditingController(text: food.calories.toString()), onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), keyboardType: TextInputType.number, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: _kWarning), textAlign: TextAlign.right, decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, hintText: '0', hintStyle: TextStyle(fontSize: 15), ), ), ), const SizedBox(width: 2), const Text('kcal', style: TextStyle(fontSize: 14, color: _kSubText)), ]), ]), ), IconButton( icon: const Icon(Icons.close, size: 19, color: AppColors.textHint), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id), ), ]), ), ), ); } // ─────────── 营养摘要 ─────────── Widget _buildNutritionSummary(int totalCalories) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: _kPrimary.withAlpha(40), blurRadius: 10, offset: const Offset(0, 4))], ), child: Row(children: [ SizedBox( width: 56, height: 56, child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 56, height: 56, child: CircularProgressIndicator( value: (totalCalories / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: Colors.white24, color: AppColors.textOnGradient, ), ), Column(mainAxisSize: MainAxisSize.min, children: [ Text('$totalCalories', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: AppColors.textOnGradient)), Text('kcal', style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)), ]), ], ), ), const SizedBox(width: 16), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('本餐热量', style: TextStyle(fontSize: 17, color: AppColors.textOnGradient)), const SizedBox(height: 4), Row(children: [ Expanded(child: _macroBar('碳水', 0.55, AppColors.warningLight)), const SizedBox(width: 8), Expanded(child: _macroBar('蛋白', 0.25, AppColors.iconBg)), const SizedBox(width: 8), Expanded(child: _macroBar('脂肪', 0.20, AppColors.errorLight)), ]), ]), ), ]), ), ); } Widget _macroBar(String label, double ratio, Color color) { return 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: 13, color: AppColors.textOnGradient)), ]), const SizedBox(height: 3), ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: Colors.white24, color: color), ), ]); } // ─────────── AI 点评 ─────────── Widget _buildAiCommentary(String text) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: AppColors.gLightPurple, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))], ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 36, height: 36, decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: const BorderRadius.all(Radius.circular(10)), ), child: const Icon(Icons.auto_awesome, size: 20, color: AppColors.textOnGradient), ), const SizedBox(width: 12), Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: _kText, height: 1.6))), ]), ), ); } // ─────────── 保存按钮 ─────────── Widget _buildSubmitButton() { return SizedBox( width: double.infinity, height: 52, child: Container( decoration: BoxDecoration( gradient: AppColors.gPurplePink, borderRadius: BorderRadius.circular(14), boxShadow: [BoxShadow(color: AppColors.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))], ), padding: const EdgeInsets.all(1.5), child: Material( color: AppTheme.surface, borderRadius: BorderRadius.circular(12.5), child: InkWell( onTap: () async { try { await ref.read(dietProvider.notifier).saveRecord(); if (mounted) popRoute(ref); } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); } }, borderRadius: BorderRadius.circular(12), child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.check_circle_outline, size: 22, color: AppColors.primary), SizedBox(width: 8), Text('保存记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)), ]), ), ), ), ); } } class _MealData { final IconData icon; final String label; final String type; const _MealData(this.icon, this.label, this.type); }