- VisionAsync 新增 Temperature=0.7, TopP=0.8 - system prompt 用专业营养识别指令 - userText 用简短"请看图识别食物"配合图片 - 修复重复 prompt 导致 VLM 误读文本的 bug
449 lines
16 KiB
Dart
449 lines
16 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import '../../core/navigation_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;
|
|
int calories;
|
|
bool selected;
|
|
|
|
FoodItem({
|
|
required this.id,
|
|
required this.name,
|
|
required this.calories,
|
|
this.selected = true,
|
|
});
|
|
}
|
|
|
|
class DietNotifier extends Notifier<DietState> {
|
|
@override
|
|
DietState build() => DietState();
|
|
|
|
void setImage(String path) {
|
|
state = state.copyWith(imagePath: path);
|
|
}
|
|
|
|
void analyzeImage() async {
|
|
state = state.copyWith(isAnalyzing: true);
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
final mockFoods = [
|
|
FoodItem(id: '1', name: '米饭', calories: 150),
|
|
FoodItem(id: '2', name: '番茄炒蛋', calories: 200),
|
|
FoodItem(id: '3', name: '红烧肉', calories: 350),
|
|
FoodItem(id: '4', name: '青菜', calories: 50),
|
|
];
|
|
state = state.copyWith(foods: mockFoods, isAnalyzing: false, healthScore: 3);
|
|
}
|
|
|
|
void updateFoodName(String id, String name) {
|
|
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, 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, 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, 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: '新食物', 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(0xFFF5F3FF),
|
|
borderRadius: BorderRadius.circular(90),
|
|
border: Border.all(color: const Color(0xFF635BFF), width: 2),
|
|
),
|
|
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF635BFF)),
|
|
),
|
|
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(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(icon, size: 32, color: const Color(0xFF635BFF)),
|
|
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(0xFF635BFF))),
|
|
]),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: isSelected ? const Color(0xFF635BFF) : const Color(0xFFF5F3FF),
|
|
foregroundColor: isSelected ? Colors.white : const Color(0xFF635BFF),
|
|
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(0xFFEDEBFF),
|
|
borderRadius: BorderRadius.circular(30),
|
|
),
|
|
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF635BFF)),
|
|
),
|
|
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(0xFFE8E6FF), 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(0xFF635BFF)),
|
|
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(0xFFF5F3FF) : 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(0xFF635BFF),
|
|
),
|
|
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),
|
|
),
|
|
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(0xFF635BFF)),
|
|
),
|
|
),
|
|
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(0xFFF5F3FF),
|
|
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(0xFFE8E6FF), 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(0xFF635BFF);
|
|
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(0xFF635BFF),
|
|
));
|
|
popRoute(ref);
|
|
},
|
|
child: const Text('保存记录'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF635BFF),
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
textStyle: const TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |