Files
AI-Health/health_app/lib/pages/diet/diet_capture_page.dart
MingNian c70d5d4be6 fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
2026-06-16 15:16:44 +08:00

894 lines
27 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 '../../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 = AppColors.primary;
const _dietAccentLight = AppColors.primarySoft;
const _dietBg = AppColors.background;
class DietCapturePage extends ConsumerStatefulWidget {
const DietCapturePage({super.key});
@override
ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
}
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(dietProvider);
return Scaffold(
backgroundColor: _dietBg,
appBar: AppBar(
backgroundColor: Colors.white,
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: [
// 图片自适应显示
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(state.imagePath!),
width: screenW - 32,
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(4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
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),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: sel ? _dietAccentLight : Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(m.$1, style: const TextStyle(fontSize: 22)),
const SizedBox(height: 2),
Text(
m.$2,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: sel ? _dietAccent : AppColors.textHint,
),
),
],
),
),
),
);
}).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: AppColors.warningLight,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$totalCal kcal',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.warning,
),
),
),
],
),
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),
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),
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,
),
hint: '0',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.warning,
),
align: TextAlign.right,
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 2),
const Text(
'kcal',
style: TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
],
),
),
],
),
],
),
),
GestureDetector(
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
child: const Icon(Icons.close, size: 18, color: AppColors.textHint),
),
],
),
);
}
Widget _compactField(
String value,
ValueChanged<String> cb, {
String? hint,
TextStyle? style,
TextAlign align = TextAlign.start,
TextInputType? keyboardType,
}) {
return TextField(
controller: TextEditingController(text: 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: AppColors.primary),
),
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: 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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'本餐热量',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
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: 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 SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
onPressed: () async {
try {
await ref.read(dietProvider.notifier).saveRecord();
if (mounted) popRoute(ref);
} catch (e) {
debugPrint('[Diet] 保存记录失败: $e');
}
},
icon: const Icon(Icons.check_circle_outline, size: 22),
label: const Text(
'保存记录',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: _dietAccent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
);
}
}