Files
AI-Health/health_app/lib/pages/diet/diet_capture_page.dart
MingNian f6dd14be3b feat: 全面UI改造 — 报告/饮食/运动/用药/侧边栏/对话流/胶囊
- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
2026-06-10 23:46:45 +08:00

488 lines
20 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!;
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(0xFFF0A060);
const _dietAccentLight = Color(0xFFFFF2E8);
const _dietBg = Color(0xFFFFFBF7);
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: LinearGradient(colors: [_dietBg, Color(0xFFFFF8F2)])),
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)),
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), 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(0xFFFFF3E0), borderRadius: BorderRadius.circular(8)),
child: Text('$totalCal kcal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFE68A00))),
),
]),
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: Color(0xFFE68A00)), 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: EdgeInsets.zero, border: InputBorder.none, 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: const Color(0xFFFFF8F0),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFE8D0)),
),
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: const Color(0xFFFFE8D0), 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: const Color(0xFFFFE8C0)),
),
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)),
),
),
);
}
}