Files
AI-Health/health_app/lib/pages/diet/diet_capture_page.dart
MingNian 4d213b5a44 feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
2026-06-20 20:41:42 +08:00

973 lines
30 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 '../../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!;
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}';
state = state.copyWith(
foods: [
...state.foods,
FoodItem(id: newId, name: '新食物', portion: '', calories: 100),
],
);
}
void removeFood(String id) {
state = state.copyWith(
foods: state.foods.where((f) => f.id != id).toList(),
);
}
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(),
},
);
}
}
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
const _dietAccent = Color(0xFFF97316);
const _dietAccentLight = Color(0xFFFFF3E0);
const _dietKcalText = Color(0xFF9A3412);
const _dietGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
);
class DietCapturePage extends ConsumerStatefulWidget {
const DietCapturePage({super.key});
@override
ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
}
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
for (final ctrl in _fieldCtrls.values) {
ctrl.dispose();
}
_fieldCtrls.clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(dietProvider);
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.9),
title: const Text(
'饮食分析',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
centerTitle: true,
),
body: state.imagePath == null
? const Center(
child: Text(
'请从首页拍摄或选择食物照片',
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
),
)
: _buildResultView(context, ref),
);
}
Widget _buildResultView(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final screenW = MediaQuery.of(context).size.width;
return Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
children: [
// 图片自适应显示
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Image.file(
File(state.imagePath!),
width: screenW - 48,
height: 220,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 16),
_buildMealSelector(ref),
const SizedBox(height: 16),
if (state.isAnalyzing)
_buildAnalyzing(state)
else ...[
if (state.foods.isNotEmpty) ...[
_buildFoodList(ref),
const SizedBox(height: 16),
_buildNutritionCard(ref),
if (state.commentary != null &&
state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildAiCommentary(state.commentary!),
],
],
const SizedBox(height: 20),
_buildSaveButton(),
],
],
),
),
);
}
// ─────────── 餐次选择器 ───────────
Widget _buildMealSelector(WidgetRef ref) {
final state = ref.watch(dietProvider);
final meals = [
('🌅', '早餐', 'breakfast'),
('☀️', '午餐', 'lunch'),
('🌙', '晚餐', 'dinner'),
('🍪', '加餐', 'snack'),
];
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white, width: 1.4),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: meals.map((m) {
final sel = state.mealType == m.$3;
return Expanded(
child: GestureDetector(
onTap: () => ref.read(dietProvider.notifier).setMealType(m.$3),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
gradient: sel ? _dietGradient : null,
color: sel ? null : const Color(0xFFFFFBF6),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: sel ? Colors.transparent : const Color(0xFFFFE4CA),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox.shrink(),
const SizedBox(height: 0),
Text(
m.$2,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: sel ? Colors.white : _dietKcalText,
),
),
],
),
),
),
);
}).toList(),
),
);
}
// ─────────── 分析中 ───────────
Widget _buildAnalyzing(DietState state) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
const SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
strokeWidth: 3,
color: _dietAccent,
),
),
const SizedBox(height: 16),
const Text(
'正在识别食物...',
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
),
if (state.errorMessage != null) ...[
const SizedBox(height: 8),
Text(
state.errorMessage!,
style: const TextStyle(fontSize: 14, color: AppColors.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 Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: _dietAccent,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'识别结果',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFFF3D8),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$totalCal kcal',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: _dietKcalText,
),
),
),
],
),
const SizedBox(height: 10),
...state.foods.map((food) => _foodItemTile(ref, food)),
const SizedBox(height: 4),
Center(
child: TextButton.icon(
onPressed: () => ref.read(dietProvider.notifier).addFood(),
icon: const Icon(
Icons.add_circle_outline,
size: 20,
color: _dietAccent,
),
label: const Text(
'添加食物',
style: TextStyle(fontSize: 15, color: _dietAccent),
),
),
),
],
),
);
}
Widget _foodItemTile(WidgetRef ref, FoodItem food) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: food.selected
? _dietAccent.withAlpha(60)
: AppColors.borderLight,
),
),
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 ? _dietAccent : Colors.white,
border: Border.all(
color: food.selected ? _dietAccent : AppColors.border,
width: 2,
),
),
child: food.selected
? const Icon(Icons.check, size: 14, color: Colors.white)
: null,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_compactField(
food.name,
(v) => ref
.read(dietProvider.notifier)
.updateFoodName(food.id, v),
fieldKey: '${food.id}_name',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
flex: 3,
child: _compactField(
food.portion,
(v) => ref
.read(dietProvider.notifier)
.updateFoodPortion(food.id, v),
fieldKey: '${food.id}_portion',
hint: '份量',
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: Row(
children: [
Expanded(
child: _compactField(
food.calories.toString(),
(v) => ref
.read(dietProvider.notifier)
.updateFoodCalories(
food.id,
int.tryParse(v) ?? 0,
),
fieldKey: '${food.id}_cal',
hint: '0',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _dietKcalText,
),
align: TextAlign.right,
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 2),
const Text(
'kcal',
style: TextStyle(
fontSize: 13,
color: Color(0xFFB45309),
),
),
],
),
),
],
),
],
),
),
GestureDetector(
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
child: const Icon(Icons.close, size: 18, color: AppColors.textHint),
),
],
),
);
}
final Map<String, TextEditingController> _fieldCtrls = {};
TextEditingController _ctrlFor(String key, String value) {
if (_fieldCtrls.containsKey(key)) return _fieldCtrls[key]!;
final c = TextEditingController(text: value);
_fieldCtrls[key] = c;
return c;
}
Widget _compactField(
String value,
ValueChanged<String> cb, {
required String fieldKey,
String? hint,
TextStyle? style,
TextAlign align = TextAlign.start,
TextInputType? keyboardType,
}) {
return TextField(
controller: _ctrlFor(fieldKey, value),
onChanged: cb,
keyboardType: keyboardType,
textAlign: align,
style: style ?? const TextStyle(fontSize: 15),
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.borderLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: _dietAccent),
),
hintText: hint,
hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
);
}
// ─────────── 营养摘要 ───────────
Widget _buildNutritionCard(WidgetRef ref) {
final state = ref.watch(dietProvider);
final totalCal = state.foods
.where((f) => f.selected)
.fold(0, (s, f) => s + f.calories);
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'本餐热量',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 14),
Row(
children: [
SizedBox(
width: 56,
height: 56,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 56,
height: 56,
child: CircularProgressIndicator(
value: (totalCal / 700).clamp(0.0, 1.0),
strokeWidth: 4,
backgroundColor: AppColors.borderLight,
color: _dietAccent,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$totalCal',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: _dietAccent,
),
),
const Text(
'kcal',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Row(
children: [
_macro('碳水', 0.55, const Color(0xFFF5A623)),
const SizedBox(width: 8),
_macro('蛋白', 0.25, const Color(0xFF4A90D9)),
const SizedBox(width: 8),
_macro('脂肪', 0.20, const Color(0xFFE8686A)),
],
),
),
],
),
],
),
);
}
Widget _macro(String label, double ratio, Color color) {
return Expanded(
child: 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: 12,
color: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: 3),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio,
minHeight: 4,
backgroundColor: AppColors.borderLight,
color: color,
),
),
],
),
);
}
// ─────────── AI 点评 ───────────
Widget _buildAiCommentary(String text) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFFBF5),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'AI建议',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _dietAccentLight,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.auto_awesome,
size: 20,
color: _dietAccent,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.6,
),
),
),
],
),
],
),
);
}
// ─────────── 保存按钮 ───────────
Widget _buildSaveButton() {
return Container(
width: double.infinity,
height: 52,
decoration: BoxDecoration(
gradient: _dietGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: _dietAccent.withValues(alpha: 0.24),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () async {
try {
await ref.read(dietProvider.notifier).saveRecord();
if (mounted) popRoute(ref);
} catch (e) {
debugPrint('[Diet] 保存记录失败: $e');
}
},
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline, size: 22, color: Colors.white),
SizedBox(width: 8),
Text(
'保存记录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
],
),
),
),
);
}
}