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提示优化中文数字
This commit is contained in:
MingNian
2026-06-10 23:46:45 +08:00
parent 63d2092c24
commit f6dd14be3b
28 changed files with 1153 additions and 1578 deletions

View File

@@ -81,14 +81,9 @@ class DietNotifier extends Notifier<DietState> {
try {
final api = ref.read(apiClientProvider);
final path = state.imagePath!;
debugPrint('[DietDebug] analyzeImage using: $path');
final imageFile = File(path);
final formData = FormData.fromMap({
'images': await MultipartFile.fromFile(
imageFile.path,
filename: imageFile.path.split('/').last,
),
'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;
@@ -96,7 +91,6 @@ class DietNotifier extends Notifier<DietState> {
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);
@@ -152,210 +146,137 @@ class DietNotifier extends Notifier<DietState> {
}).toList();
} catch (_) {
return [
FoodItem(
id: 'food_${DateTime.now().millisecondsSinceEpoch}',
name: '识别结果(手动编辑)',
portion: raw.length > 50 ? raw.substring(0, 50) : raw,
calories: 0,
selected: true,
),
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}';
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)];
state = state.copyWith(foods: foods);
}
void removeFood(String id) {
final foods = state.foods.where((f) => f.id != id).toList();
state = state.copyWith(foods: foods);
}
void setMealType(String type) {
state = state.copyWith(mealType: type);
}
void reset() {
state = DietState();
}
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(),
'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();
// 不 reset — 图片由 home_page 传入,这里只做展示
}
@override void initState() { super.initState(); }
@override
Widget build(BuildContext context) {
final state = ref.watch(dietProvider);
return Scaffold(
backgroundColor: _dietBg,
appBar: AppBar(
title: const Text('拍饮食'),
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)))
? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary, fontSize: 16)))
: _buildResultView(context, ref),
);
}
// ─────────── 设计常量(与项目整体色调一致)───────────
static const _kPrimary = AppTheme.primary; // #6C5CE7
static const _kPrimaryLight = AppTheme.primaryLight; // #EDEAFF
static const _kPageBg = AppTheme.bg; // #F8F9FC
static const _kSurface = AppTheme.surface; // white
static const _kText = AppTheme.text; // #2D2B32
static const _kSubText = AppTheme.textSub; // #8A8892
static const _kBorder = AppTheme.border; // #EAEAF0
static const _kWarning = AppTheme.warning; // #F5A623
Widget _buildResultView(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
final screenW = MediaQuery.of(context).size.width;
return Container(
color: AppColors.background,
decoration: const BoxDecoration(gradient: LinearGradient(colors: [_dietBg, Color(0xFFFFF8F2)])),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(children: [
_buildImagePreview(state.imagePath!),
// 图片自适应显示
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)
_buildAnalyzingIndicator(state)
_buildAnalyzing(state)
else ...[
if (state.foods.isNotEmpty) ...[
_buildFoodList(ref),
const SizedBox(height: 16),
_buildNutritionSummary(totalCalories),
_buildNutritionCard(ref),
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildAiCommentary(state.commentary!),
],
],
const SizedBox(height: 20),
_buildSubmitButton(),
const SizedBox(height: 16),
_buildSaveButton(),
],
]),
),
);
}
// ─────────── 图片预览 ───────────
Widget _buildImagePreview(String path) {
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
height: 200,
width: MediaQuery.of(context).size.width * 0.75,
decoration: BoxDecoration(
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 14, offset: const Offset(0, 4))],
),
foregroundDecoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Colors.transparent, Colors.black38],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
),
);
}
// ─────────── 餐次选择器 ───────────
Widget _buildMealSelector(WidgetRef ref) {
final state = ref.watch(dietProvider);
final meals = [
_MealData(Icons.wb_sunny_outlined, '早餐', 'breakfast'),
_MealData(Icons.wb_cloudy_outlined, '午餐', 'lunch'),
_MealData(Icons.nightlight_round, '晚餐', 'dinner'),
_MealData(Icons.local_cafe_outlined, '加餐', 'snack'),
('🌅', '早餐', 'breakfast'),
('☀️', '午餐', 'lunch'),
('🌙', '晚餐', 'dinner'),
('🍪', '加餐', 'snack'),
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
child: Row(
children: meals.map((m) {
final isSelected = state.mealType == m.type;
final sel = state.mealType == m.$3;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: GestureDetector(
onTap: () => ref.read(dietProvider.notifier).setMealType(m.type),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? _kPrimary : _kSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: isSelected ? _kPrimary : _kBorder),
boxShadow: isSelected
? [BoxShadow(color: _kPrimary.withAlpha(30), blurRadius: 6, offset: const Offset(0, 2))]
: null,
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(m.icon, size: 23, color: isSelected ? AppColors.textOnGradient : _kSubText),
const SizedBox(height: 4),
Text(m.label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isSelected ? AppColors.textOnGradient : _kSubText)),
]),
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)),
]),
),
),
);
@@ -364,30 +285,19 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
);
}
// ─────────── 分析中指示器 ───────────
Widget _buildAnalyzingIndicator(DietState state) {
// ─────────── 分析中 ───────────
Widget _buildAnalyzing(DietState state) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Column(children: [
SizedBox(
width: 56, height: 56,
child: Stack(
alignment: Alignment.center,
children: [
const CircularProgressIndicator(strokeWidth: 3, color: _kPrimary),
const Icon(Icons.restaurant, size: 25, color: _kPrimary),
],
),
),
const SizedBox(height: 20),
const Text('正在识别食物...', style: TextStyle(fontSize: 18, color: _kSubText)),
if (state.errorMessage != null) ...[
const SizedBox(height: 8),
Text(state.errorMessage!, style: const TextStyle(fontSize: 16, color: AppTheme.error), textAlign: TextAlign.center),
],
]),
),
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),
],
]),
);
}
@@ -395,268 +305,183 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
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 Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: _kSurface,
borderRadius: BorderRadius.circular(16),
boxShadow: [AppTheme.shadowLight],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 0),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)),
child: const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kPrimary)),
),
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: 16, fontWeight: FontWeight.w600, color: _kWarning)),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 25, color: _kPrimary),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => ref.read(dietProvider.notifier).addFood(),
),
]),
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: 8),
...state.foods.map((food) => _buildFoodItem(ref, food)),
const SizedBox(height: 12),
]),
),
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 _buildFoodItem(WidgetRef ref, FoodItem food) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: food.selected ? AppColors.iconBg : AppColors.background,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : AppColors.border),
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,
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 6, 10),
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 ? _kPrimary : AppTheme.surface,
border: Border.all(color: food.selected ? _kPrimary : AppColors.border, width: 2),
),
child: food.selected ? const Icon(Icons.check, size: 17, color: AppColors.textOnGradient) : null,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(
controller: TextEditingController(text: food.name),
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _kText),
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
),
),
const SizedBox(height: 2),
Row(children: [
Expanded(
flex: 3,
child: TextField(
controller: TextEditingController(text: food.portion),
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v),
style: const TextStyle(fontSize: 15, color: _kSubText),
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
hintText: '份量',
hintStyle: TextStyle(fontSize: 15, color: AppTheme.textHint),
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: TextField(
controller: TextEditingController(text: food.calories.toString()),
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: _kWarning),
textAlign: TextAlign.right,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
hintText: '0',
hintStyle: TextStyle(fontSize: 15),
),
),
),
const SizedBox(width: 2),
const Text('kcal', style: TextStyle(fontSize: 14, color: _kSubText)),
]),
]),
),
IconButton(
icon: const Icon(Icons.close, size: 19, color: AppColors.textHint),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
),
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 _buildNutritionSummary(int totalCalories) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: _kPrimary.withAlpha(40), blurRadius: 10, offset: const Offset(0, 4))],
),
child: Row(children: [
SizedBox(
width: 56, height: 56,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 56, height: 56,
child: CircularProgressIndicator(
value: (totalCalories / 700).clamp(0.0, 1.0),
strokeWidth: 4,
backgroundColor: Colors.white24,
color: AppColors.textOnGradient,
),
),
Column(mainAxisSize: MainAxisSize.min, children: [
Text('$totalCalories', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: AppColors.textOnGradient)),
Text('kcal', style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)),
]),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('本餐热量', style: TextStyle(fontSize: 17, color: AppColors.textOnGradient)),
const SizedBox(height: 4),
Row(children: [
Expanded(child: _macroBar('碳水', 0.55, AppColors.warningLight)),
const SizedBox(width: 8),
Expanded(child: _macroBar('蛋白', 0.25, AppColors.iconBg)),
const SizedBox(width: 8),
Expanded(child: _macroBar('脂肪', 0.20, AppColors.errorLight)),
]),
]),
),
]),
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 _macroBar(String label, double ratio, Color color) {
return 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: 13, color: AppColors.textOnGradient)),
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)),
]),
const SizedBox(height: 3),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: Colors.white24, color: color),
),
]);
);
}
// ─────────── AI 点评 ───────────
Widget _buildAiCommentary(String text) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: AppColors.gLightPurple,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))],
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: const Icon(Icons.auto_awesome, size: 20, color: AppColors.textOnGradient),
),
const SizedBox(width: 12),
Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: _kText, height: 1.6))),
]),
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 _buildSubmitButton() {
Widget _buildSaveButton() {
return SizedBox(
width: double.infinity,
height: 52,
child: Container(
decoration: BoxDecoration(
gradient: AppColors.gPurplePink,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: AppColors.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))],
),
padding: const EdgeInsets.all(1.5),
child: Material(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(12.5),
child: InkWell(
onTap: () async {
try {
await ref.read(dietProvider.notifier).saveRecord();
if (mounted) popRoute(ref);
} catch (e) { debugPrint('[Diet] 保存记录失败: $e'); }
},
borderRadius: BorderRadius.circular(12),
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.check_circle_outline, size: 22, color: AppColors.primary),
SizedBox(width: 8),
Text('保存记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
]),
),
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)),
),
),
);
}
}
class _MealData {
final IconData icon;
final String label;
final String type;
const _MealData(this.icon, this.label, this.type);
}