Files
AI-Health/health_app/lib/pages/diet/diet_capture_page.dart
MingNian 53ce19e8c3 feat: 健康录入提醒 + 多指标异常 + 用药漏服 + UI 优化
后端
- NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重)
- EF 迁移 AddHealthRecordReminder
- 新增 GET/PUT /api/notification-prefs
- 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒

前端
- 通知偏好对接真实后端 API
- 设置页新增「健康录入提醒」总开关+5 子项
- 通知中心右上角加齿轮跳设置页
- 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案
- 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变
- 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变
- 设置页简化(去掉分组/彩色图标方块)
- 健康档案保存按钮移到右上角,TextButton 全局默认黑色
- API baseUrl 跟随网络环境
2026-06-24 22:11:21 +08:00

970 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 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,
),
),
],
),
),
),
);
}
}