Files
AI-Health/health_app/lib/pages/diet/diet_capture_page.dart
MingNian 63d2092c24 feat: UI全面改造 + 后端多项修复
【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

【前端UI重构】
- 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景)
- 登录页重设计
- 首页重设计(透明顶栏、渐变背景、胶囊输入区)
- 聊天卡片加白蓝边框、渐变标题
- 侧边栏重构(渐变背景、合并顶部、删除底部设置)
- 确认卡片可编辑字段恢复
- 所有子页面加返回按钮
- catch异常加日志
- 删除后refresh provider缓存
2026-06-10 18:27:38 +08:00

662 lines
25 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 '../../core/app_colors.dart';
import '../../core/app_theme.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 (e) {
debugPrint('[Diet] 获取饮食点评失败: $e');
}
}
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 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<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();
// 不 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);
}