- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService - 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放 - UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面 - 配置: api_client baseUrl 适配当前 WiFi IP
1006 lines
31 KiB
Dart
1006 lines
31 KiB
Dart
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.sizeOf(context).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.isEmpty)
|
||
_buildNoFoodHint()
|
||
else ...[
|
||
_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 _buildNoFoodHint() {
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 18),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(18),
|
||
border: Border.all(color: AppColors.borderLight),
|
||
boxShadow: AppColors.cardShadowLight,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
const Icon(
|
||
Icons.image_not_supported_outlined,
|
||
size: 44,
|
||
color: AppColors.textHint,
|
||
),
|
||
const SizedBox(height: 14),
|
||
const Text(
|
||
'未识别到食物',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
const Text(
|
||
'请重新拍摄或选择含有食物的清晰照片',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────── 食物列表 ───────────
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|