- 用药: 重写列表页(标签筛选+详情弹窗+打卡切换+滑动删除) - 用药: 重写编辑页(选择器替代输入+同列布局+频率改为每天几次) - 用药: 加Notes字段+DELETE端点+修复重复端点 - 运动: 不限7天, startDate+索引推算当天 - 打卡: 药/运动打卡可切换(点✓取消) - 删除: 修复滑动删除热区, 一次点击即删除 - 侧边栏: 一键清理历史对话 - 饮食/运动/用药/问诊 API路径加/api/前缀 - 通知时机修正: 新建计划不再提前弹窗
734 lines
28 KiB
Dart
734 lines
28 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 'package:image_picker/image_picker.dart';
|
||
import '../../core/navigation_provider.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!;
|
||
debugPrint('[DietDebug] analyzeImage using: $path');
|
||
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 (_) {}
|
||
}
|
||
|
||
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}';
|
||
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();
|
||
}
|
||
|
||
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(),
|
||
});
|
||
}
|
||
}
|
||
|
||
class DietCapturePage extends ConsumerStatefulWidget {
|
||
const DietCapturePage({super.key});
|
||
@override ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
||
}
|
||
|
||
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||
@override void initState() {
|
||
super.initState();
|
||
Future.microtask(() => ref.read(dietProvider.notifier).reset());
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(dietProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('拍饮食'),
|
||
centerTitle: true,
|
||
),
|
||
body: state.imagePath == null ? _buildCaptureView(context, ref) : _buildResultView(context, ref),
|
||
);
|
||
}
|
||
|
||
Widget _buildCaptureView(BuildContext context, WidgetRef ref) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 180,
|
||
height: 180,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF0F2FF),
|
||
borderRadius: BorderRadius.circular(90),
|
||
border: Border.all(color: const Color(0xFF8B9CF7), width: 2),
|
||
),
|
||
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF8B9CF7)),
|
||
),
|
||
const SizedBox(height: 24),
|
||
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||
const SizedBox(height: 8),
|
||
const Text('AI将识别食物并分析营养成分', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||
const SizedBox(height: 40),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_captureBtn(context, ref, Icons.camera_alt, '拍照', ImageSource.camera),
|
||
const SizedBox(width: 24),
|
||
_captureBtn(context, ref, Icons.photo_library, '相册', ImageSource.gallery),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _captureBtn(BuildContext context, WidgetRef ref, IconData icon, String label, ImageSource source) {
|
||
return Column(
|
||
children: [
|
||
Container(
|
||
width: 80,
|
||
height: 80,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFEFEFF),
|
||
borderRadius: BorderRadius.circular(20),
|
||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
|
||
),
|
||
child: IconButton(
|
||
icon: Icon(icon, size: 32, color: const Color(0xFF8B9CF7)),
|
||
onPressed: () => _pickImage(context, ref, source),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
|
||
final picker = ImagePicker();
|
||
final picked = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
|
||
if (picked != null) {
|
||
ref.read(dietProvider.notifier).setImage(picked.path);
|
||
ref.read(dietProvider.notifier).analyzeImage();
|
||
}
|
||
}
|
||
|
||
// ─────────── 设计常量(与项目整体色调一致)───────────
|
||
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);
|
||
|
||
return Container(
|
||
color: _kPageBg,
|
||
child: SingleChildScrollView(
|
||
child: Column(children: [
|
||
_buildImagePreview(state.imagePath!),
|
||
const SizedBox(height: 20),
|
||
_buildMealSelector(ref),
|
||
const SizedBox(height: 16),
|
||
if (state.isAnalyzing)
|
||
_buildAnalyzingIndicator(state)
|
||
else ...[
|
||
if (state.foods.isNotEmpty) ...[
|
||
_buildFoodList(ref),
|
||
const SizedBox(height: 16),
|
||
_buildNutritionSummary(totalCalories),
|
||
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
||
const SizedBox(height: 16),
|
||
_buildAiCommentary(state.commentary!),
|
||
],
|
||
],
|
||
const SizedBox(height: 24),
|
||
_buildSubmitButton(context, ref),
|
||
const SizedBox(height: 16),
|
||
],
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────── 图片预览 ───────────
|
||
Widget _buildImagePreview(String path) {
|
||
return Stack(
|
||
children: [
|
||
Container(
|
||
height: 220,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFE0E0E0),
|
||
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
|
||
),
|
||
),
|
||
Positioned(
|
||
left: 0, right: 0, bottom: 0,
|
||
child: Container(
|
||
height: 60,
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [Colors.transparent, Color(0x40000000)],
|
||
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'),
|
||
];
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
children: meals.map((m) {
|
||
final isSelected = state.mealType == m.type;
|
||
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: 20, color: isSelected ? Colors.white : _kSubText),
|
||
const SizedBox(height: 4),
|
||
Text(m.label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : _kSubText)),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────── 分析中指示器 ───────────
|
||
Widget _buildAnalyzingIndicator(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: 22, color: _kPrimary),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
const Text('正在识别食物...', style: TextStyle(fontSize: 15, color: _kSubText)),
|
||
if (state.errorMessage != null) ...[
|
||
const SizedBox(height: 8),
|
||
Text(state.errorMessage!, style: const TextStyle(fontSize: 13, color: AppTheme.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 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: 13, fontWeight: FontWeight.w600, color: _kPrimary)),
|
||
),
|
||
const Spacer(),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(color: const Color(0xFFFFF8EE), borderRadius: BorderRadius.circular(8)),
|
||
child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: _kWarning)),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.add_circle_outline, size: 22, color: _kPrimary),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
||
),
|
||
]),
|
||
),
|
||
const SizedBox(height: 8),
|
||
...state.foods.map((food) => _buildFoodItem(ref, food)),
|
||
const SizedBox(height: 12),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
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 ? const Color(0xFFF6F4FF) : const Color(0xFFFAFAFA),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : const Color(0xFFEEEEEE)),
|
||
),
|
||
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 : Colors.white,
|
||
border: Border.all(color: food.selected ? _kPrimary : const Color(0xFFCCCCCC), width: 2),
|
||
),
|
||
child: food.selected ? const Icon(Icons.check, size: 14, color: Colors.white) : 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: 15, 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: 12, color: _kSubText),
|
||
decoration: const InputDecoration(
|
||
isDense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
border: InputBorder.none,
|
||
hintText: '份量',
|
||
hintStyle: TextStyle(fontSize: 12, 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: 12, fontWeight: FontWeight.w600, color: _kWarning),
|
||
textAlign: TextAlign.right,
|
||
decoration: const InputDecoration(
|
||
isDense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
border: InputBorder.none,
|
||
hintText: '0',
|
||
hintStyle: TextStyle(fontSize: 12),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 2),
|
||
const Text('kcal', style: TextStyle(fontSize: 11, color: _kSubText)),
|
||
]),
|
||
]),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB)),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
||
),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────── 营养摘要 ───────────
|
||
Widget _buildNutritionSummary(int totalCalories) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
gradient: const LinearGradient(colors: [_kPrimary, Color(0xFF8B7CF0)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||
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: Colors.white,
|
||
),
|
||
),
|
||
Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Text('$totalCalories', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800, color: Colors.white)),
|
||
Text('kcal', style: const TextStyle(fontSize: 10, color: Colors.white70)),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
const Text('本餐热量', style: TextStyle(fontSize: 14, color: Colors.white70)),
|
||
const SizedBox(height: 4),
|
||
Row(children: [
|
||
Expanded(child: _macroBar('碳水', 0.55, const Color(0xFFFFF9C4))),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: _macroBar('蛋白', 0.25, const Color(0xFFD4C8FC))),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: _macroBar('脂肪', 0.20, const Color(0xFFFFCDD2))),
|
||
]),
|
||
]),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
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: 10, color: Colors.white70)),
|
||
]),
|
||
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(14),
|
||
decoration: BoxDecoration(
|
||
color: _kSurface,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(color: _kBorder),
|
||
),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Container(
|
||
width: 32, height: 32,
|
||
decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)),
|
||
child: const Icon(Icons.auto_awesome, size: 16, color: _kPrimary),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13, color: _kText, height: 1.6))),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─────────── 保存按钮 ───────────
|
||
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
onPressed: () async {
|
||
try {
|
||
await ref.read(dietProvider.notifier).saveRecord();
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||
content: const Text('饮食记录已保存'),
|
||
backgroundColor: _kPrimary,
|
||
behavior: SnackBarBehavior.floating,
|
||
duration: const Duration(seconds: 2),
|
||
));
|
||
popRoute(ref);
|
||
}
|
||
} catch (e) {
|
||
if (context.mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||
content: Text('保存失败: $e'),
|
||
backgroundColor: AppTheme.error,
|
||
behavior: SnackBarBehavior.floating,
|
||
));
|
||
}
|
||
}
|
||
},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: _kPrimary,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.radiusMd)),
|
||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
Icon(Icons.check_circle_outline, size: 20),
|
||
SizedBox(width: 8),
|
||
Text('保存记录'),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MealData {
|
||
final IconData icon;
|
||
final String label;
|
||
final String type;
|
||
const _MealData(this.icon, this.label, this.type);
|
||
} |