Files
AI-Health/health_app/lib/pages/diet/diet_capture_page.dart
MingNian c2399b952f refactor: 4层架构重构 + 饮食VLM接入 + 多项修复
- 后端: remaining_endpoints拆分为6个独立文件
- 后端: AI Agent Handler从ai_chat_endpoints抽取为7个独立处理器
- 后端: 食物识别prompt改为输出结构化JSON
- 前端: 饮食识别从Mock替换为真实VLM API调用
- 前端: 首页图片上传URL修复(/api/upload→/api/files/upload)
- 前端: 拍饮食按钮导航到独立DietCapturePage
- 前端: 删除无用agent_bar.dart
- 前端: 修复widget_test.dart过期属性名
- 前端: 恢复ServicePackageCard和详情页
- 新增6份实施文档(情况/问诊/报告/建档/日历/视觉统一)
2026-06-03 23:17:37 +08:00

514 lines
18 KiB
Dart

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';
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;
DietState({
this.imagePath,
this.foods = const [],
this.mealType = 'lunch',
this.isAnalyzing = false,
this.healthScore,
});
DietState copyWith({
String? imagePath,
List<FoodItem>? foods,
String? mealType,
bool? isAnalyzing,
int? healthScore,
}) {
return DietState(
imagePath: imagePath ?? this.imagePath,
foods: foods ?? this.foods,
mealType: mealType ?? this.mealType,
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
healthScore: healthScore ?? this.healthScore,
);
}
}
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);
}
String? _analysisError;
Future<void> analyzeImage() async {
state = state.copyWith(isAnalyzing: true);
_analysisError = null;
try {
final api = ref.read(apiClientProvider);
final imageFile = File(state.imagePath!);
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) {
_analysisError = data['message'] ?? '识别失败';
state = state.copyWith(isAnalyzing: false);
return;
}
final raw = data['data'] as String? ?? '[]';
final foods = _parseFoodItems(raw);
state = state.copyWith(
foods: foods,
isAnalyzing: false,
healthScore: foods.isNotEmpty ? 3 : null,
);
} catch (e) {
_analysisError = '识别失败: $e';
state = state.copyWith(isAnalyzing: false);
}
}
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();
}
}
class DietCapturePage extends ConsumerWidget {
const DietCapturePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
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: 85);
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: 20),
_buildMealSelector(context, ref),
const SizedBox(height: 20),
if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref),
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
const SizedBox(height: 20),
_buildNutritionSummary(totalCalories),
const SizedBox(height: 20),
_buildHealthScore(state.healthScore ?? 0),
const SizedBox(height: 30),
_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() {
return Center(
child: Column(children: [
Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(30),
),
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)),
),
const SizedBox(height: 16),
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
]),
);
}
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))),
]),
);
}
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: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('饮食记录已保存 ✅'),
backgroundColor: Color(0xFF8B9CF7),
));
popRoute(ref);
},
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),
),
),
);
}
}