Files
AI-Health/health_app/lib/pages/diet/diet_capture_page.dart
MingNian b944a31983 fix: VLM识别修复 + 饮食CRUD + 侧边栏美化 + UI优化
- VLM: ChatMessage.Content string→object, 修复视觉content双重序列化
- 饮食: diet/medication端点 record→手动JSON解析, 修复循环引用
- 饮食记录: 左滑删除 + 去评分 + AI饮食评语(DeepSeek)
- 侧边栏: 功能区统一Row+Expanded, 服务包compact模式
- 历史对话: 点击加载历史消息
- 登录页: 居中布局, 去底部空白
- UI: 主色加深#6C5CE7, maxWidth:1024防图片超大
2026-06-04 16:27:03 +08:00

603 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '../../providers/auth_provider.dart';
import '../../utils/sse_handler.dart';
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
class DietState {
final String? imagePath;
final List<FoodItem> 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<FoodItem>? 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<DietState> {
@override
DietState build() => DietState();
void setImage(String path) {
state = state.copyWith(imagePath: path);
}
Future<void> 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<void> _fetchCommentary(List<FoodItem> 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 (_) {}
}
List<FoodItem> _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<String, dynamic>;
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 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<void> 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<int>(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<DietCapturePage> createState() => _DietCapturePageState();
}
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
@override void initState() {
super.initState();
Future.microtask(() => ref.read(dietProvider.notifier).reset());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(dietProvider);
return Scaffold(
appBar: AppBar(
title: const Text('拍饮食'),
centerTitle: true,
),
body: state.imagePath == null ? _buildCaptureView(context, ref) : _buildResultView(context, ref),
);
}
Widget _buildCaptureView(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(90),
border: Border.all(color: const Color(0xFF8B9CF7), width: 2),
),
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF8B9CF7)),
),
const SizedBox(height: 24),
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
const Text('AI将识别食物并分析营养成分', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_captureBtn(context, ref, Icons.camera_alt, '拍照', ImageSource.camera),
const SizedBox(width: 24),
_captureBtn(context, ref, Icons.photo_library, '相册', ImageSource.gallery),
],
),
],
),
);
}
Widget _captureBtn(BuildContext context, WidgetRef ref, IconData icon, String label, ImageSource source) {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
),
child: IconButton(
icon: Icon(icon, size: 32, color: const Color(0xFF8B9CF7)),
onPressed: () => _pickImage(context, ref, source),
),
),
const SizedBox(height: 8),
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
],
);
}
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
if (picked != null) {
ref.read(dietProvider.notifier).setImage(picked.path);
ref.read(dietProvider.notifier).analyzeImage();
}
}
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 SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(children: [
_buildImagePreview(state.imagePath!),
const SizedBox(height: 16),
_buildMealSelector(context, ref),
const SizedBox(height: 16),
if (state.isAnalyzing) _buildAnalyzingIndicator(state) else ...[
_buildFoodList(context, ref),
if (state.foods.isNotEmpty) ...[
const SizedBox(height: 16),
_buildNutritionSummary(totalCalories),
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildAiCommentary(state.commentary!),
],
const SizedBox(height: 24),
_buildSubmitButton(context, ref),
],
],
]),
);
}
Widget _buildImagePreview(String path) {
return Container(
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
),
);
}
Widget _buildMealSelector(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final meals = [
{'type': 'breakfast', 'label': '早餐', 'icon': '🌅'},
{'type': 'lunch', 'label': '午餐', 'icon': '☀️'},
{'type': 'dinner', 'label': '晚餐', 'icon': '🌙'},
{'type': 'snack', 'label': '加餐', 'icon': '🍪'},
];
return Column(children: [
const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
const SizedBox(height: 12),
Row(children: meals.map((meal) {
final isSelected = state.mealType == meal['type'];
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ElevatedButton(
onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!),
child: Column(children: [
Text(meal['icon']!, style: const TextStyle(fontSize: 20)),
const SizedBox(height: 4),
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
]),
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? const Color(0xFF8B9CF7) : const Color(0xFFF0F2FF),
foregroundColor: isSelected ? Colors.white : const Color(0xFF8B9CF7),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
);
}).toList()),
]);
}
Widget _buildAnalyzingIndicator(DietState state) {
return Center(
child: Column(children: [
Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEDEAFF),
borderRadius: BorderRadius.circular(30),
),
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF6C5CE7)),
),
const SizedBox(height: 16),
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
if (state.errorMessage != null) ...[
const SizedBox(height: 8),
Text(state.errorMessage!, style: const TextStyle(fontSize: 13, color: Color(0xFFE53935)), textAlign: TextAlign.center),
],
]),
);
}
Widget _buildFoodList(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
return Container(
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
),
child: Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
const Text('🍽️', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
icon: const Icon(Icons.add, size: 20, color: Color(0xFF8B9CF7)),
onPressed: () => ref.read(dietProvider.notifier).addFood(),
),
]),
),
...state.foods.map((food) => _buildFoodItem(context, ref, food)),
]),
);
}
Widget _buildFoodItem(BuildContext context, WidgetRef ref, FoodItem food) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: food.selected ? const Color(0xFFF0F2FF) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(16),
),
child: Row(children: [
Checkbox(
value: food.selected,
onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id),
activeColor: const Color(0xFF8B9CF7),
),
const SizedBox(width: 8),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(
decoration: const InputDecoration(border: InputBorder.none, hintText: '食物名称'),
controller: TextEditingController(text: food.name),
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
style: const TextStyle(fontSize: 16),
),
if (food.portion.isNotEmpty)
Text(food.portion, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
Row(children: [
const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
SizedBox(
width: 60,
child: TextField(
decoration: const InputDecoration(border: InputBorder.none, hintText: '0'),
controller: TextEditingController(text: food.calories.toString()),
keyboardType: TextInputType.number,
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
style: TextStyle(fontSize: 12, color: const Color(0xFF8B9CF7)),
),
),
const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
]),
),
IconButton(
icon: const Icon(Icons.delete, size: 18, color: Color(0xFFCCCCCC)),
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
),
]),
);
}
Widget _buildNutritionSummary(int totalCalories) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(16),
),
child: Row(children: [
const Icon(Icons.fireplace, size: 28, color: Color(0xFFFF6B35)),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
Text('$totalCalories kcal', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)),
]),
const Spacer(),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
const Text('推荐摄入量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
const Text('午餐约 500-700 kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
]),
);
}
Widget _buildHealthScore(int score) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
),
child: Column(children: [
const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (i) => Icon(
Icons.star,
size: 36,
color: i < score ? const Color(0xFFFFB800) : Colors.grey[200],
)),
),
const SizedBox(height: 12),
Text(_getScoreComment(score), style: TextStyle(fontSize: 14, color: _getScoreColor(score))),
]),
);
}
Widget _buildAiCommentary(String text) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFEDEAFF), Color(0xFFF5F3FF)], begin: Alignment.topLeft, end: Alignment.bottomRight),
borderRadius: BorderRadius.circular(16),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: 32, height: 32, decoration: BoxDecoration(color: const Color(0xFF6C5CE7).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF6C5CE7))),
const SizedBox(width: 10),
Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: Color(0xFF444444), height: 1.5))),
]),
);
}
String _getScoreComment(int score) {
switch (score) {
case 1: return '饮食不太健康,建议多吃蔬菜';
case 2: return '需要改善,减少油腻食物';
case 3: return '还不错,继续保持均衡饮食';
case 4: return '很健康!营养搭配合理';
case 5: return '非常健康!饮食管理很棒';
default: return '请完善食物信息';
}
}
Color _getScoreColor(int score) {
switch (score) {
case 1: return const Color(0xFFE53935);
case 2: return const Color(0xFFF9A825);
case 3: return const Color(0xFF8B9CF7);
case 4: return const Color(0xFF43A047);
case 5: return const Color(0xFF00C853);
default: return Colors.grey[400]!;
}
}
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
try {
await ref.read(dietProvider.notifier).saveRecord();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('饮食记录已保存 ✅'),
backgroundColor: Color(0xFF43A047),
));
popRoute(ref);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('保存失败: $e'),
backgroundColor: const Color(0xFFE53935),
));
}
}
},
child: const Text('保存记录'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8B9CF7),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 16),
),
),
);
}
}