fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者 - 注册页重构:去掉角色选择+审核码,改为选医生+填姓名 - 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立 - Doctor/DoctorProfile/User实体新增关联字段 - JSON循环引用修复(IgnoreCycles) - /api/doctors改为公开接口 - 登录闪屏修复+原生Android启动页 - 输入框全局白底+灰框 - 蓝牙重连同步修复 - Web端(doctor_web+health_app/web)全部删除 - 全局UI改版:白底企业风,新配色和组件系统 - 新品牌图标和启动图
This commit is contained in:
@@ -3,14 +3,14 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/sse_handler.dart';
|
||||
|
||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(
|
||||
DietNotifier.new,
|
||||
);
|
||||
|
||||
class DietState {
|
||||
final String? imagePath;
|
||||
@@ -83,17 +83,30 @@ class DietNotifier extends Notifier<DietState> {
|
||||
final path = state.imagePath!;
|
||||
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 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'] ?? '识别失败');
|
||||
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);
|
||||
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: '识别失败,请重试');
|
||||
@@ -105,7 +118,9 @@ class DietNotifier extends Notifier<DietState> {
|
||||
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 names = foods
|
||||
.map((f) => '${f.name}(${f.portion},${f.calories}kcal)')
|
||||
.join('、');
|
||||
final stream = SseHandler.connect(
|
||||
agentType: 'default',
|
||||
message: '我刚才吃了这些:$names。请结合我的健康档案,给我简短的饮食评价和建议(50字以内)。',
|
||||
@@ -113,7 +128,8 @@ class DietNotifier extends Notifier<DietState> {
|
||||
);
|
||||
String text = '';
|
||||
await for (final event in stream) {
|
||||
if (event['action'] == 'answer') text += (event['data'] as String?) ?? '';
|
||||
if (event['action'] == 'answer')
|
||||
text += (event['data'] as String?) ?? '';
|
||||
if (event['action'] == 'status') {
|
||||
if (text.isNotEmpty) state = state.copyWith(commentary: text.trim());
|
||||
}
|
||||
@@ -146,48 +162,160 @@ 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}'; 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(); }
|
||||
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 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(),
|
||||
});
|
||||
await api.post(
|
||||
'/api/diet-records',
|
||||
data: {
|
||||
'mealType': mealMap[state.mealType] ?? 'Lunch',
|
||||
'totalCalories': totalCal,
|
||||
'healthScore': state.healthScore ?? 3,
|
||||
'recordedAt': DateTime.now().toIso8601String().substring(0, 10),
|
||||
'foodItems': selectedFoods
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(e) => {
|
||||
'name': e.value.name,
|
||||
'portion': e.value.portion,
|
||||
'calories': e.value.calories,
|
||||
'sortOrder': e.key,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
||||
const _dietAccent = Color(0xFFF0A060);
|
||||
const _dietAccentLight = Color(0xFFFFF2E8);
|
||||
const _dietBg = Color(0xFFFFFBF7);
|
||||
const _dietAccent = AppColors.primary;
|
||||
const _dietAccentLight = AppColors.primarySoft;
|
||||
const _dietBg = AppColors.background;
|
||||
|
||||
class DietCapturePage extends ConsumerStatefulWidget {
|
||||
const DietCapturePage({super.key});
|
||||
@override ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
||||
@override
|
||||
ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
||||
}
|
||||
|
||||
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
@override void initState() { super.initState(); }
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -196,11 +324,19 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
backgroundColor: _dietBg,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
title: const Text('饮食分析', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||
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)))
|
||||
? const Center(
|
||||
child: Text(
|
||||
'请从首页拍摄或选择食物照片',
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||
),
|
||||
)
|
||||
: _buildResultView(context, ref),
|
||||
);
|
||||
}
|
||||
@@ -210,39 +346,42 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
final screenW = MediaQuery.of(context).size.width;
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(gradient: LinearGradient(colors: [_dietBg, Color(0xFFFFF8F2)])),
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(children: [
|
||||
// 图片自适应显示
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(state.imagePath!),
|
||||
width: screenW - 32,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
child: Column(
|
||||
children: [
|
||||
// 图片自适应显示
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(state.imagePath!),
|
||||
width: screenW - 32,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildMealSelector(ref),
|
||||
const SizedBox(height: 16),
|
||||
if (state.isAnalyzing)
|
||||
_buildAnalyzing(state)
|
||||
else ...[
|
||||
if (state.foods.isNotEmpty) ...[
|
||||
_buildFoodList(ref),
|
||||
const SizedBox(height: 16),
|
||||
_buildNutritionCard(ref),
|
||||
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildMealSelector(ref),
|
||||
const SizedBox(height: 16),
|
||||
if (state.isAnalyzing)
|
||||
_buildAnalyzing(state)
|
||||
else ...[
|
||||
if (state.foods.isNotEmpty) ...[
|
||||
_buildFoodList(ref),
|
||||
const SizedBox(height: 16),
|
||||
_buildAiCommentary(state.commentary!),
|
||||
_buildNutritionCard(ref),
|
||||
if (state.commentary != null &&
|
||||
state.commentary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildAiCommentary(state.commentary!),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_buildSaveButton(),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_buildSaveButton(),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -258,7 +397,12 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: meals.map((m) {
|
||||
final sel = state.mealType == m.$3;
|
||||
@@ -272,11 +416,21 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
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)),
|
||||
]),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -289,48 +443,110 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
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),
|
||||
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);
|
||||
final totalCal = state.foods
|
||||
.where((f) => f.selected)
|
||||
.fold(0, (s, f) => s + f.calories);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: AppColors.cardShadowLight),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: _dietAccent, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFE68A00))),
|
||||
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: AppColors.warningLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'共 $totalCal kcal',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
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)),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,103 +557,270 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: food.selected ? _dietAccent.withAlpha(60) : AppColors.borderLight),
|
||||
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: 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: food.selected ? const Icon(Icons.check, size: 14, color: Colors.white) : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_compactField(food.name, (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Expanded(flex: 3, child: _compactField(food.portion, (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v), hint: '份量', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(flex: 2, child: Row(children: [
|
||||
Expanded(child: _compactField(food.calories.toString(), (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), hint: '0', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFE68A00)), align: TextAlign.right, keyboardType: TextInputType.number)),
|
||||
const SizedBox(width: 2),
|
||||
const Text('kcal', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
])),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
||||
child: const Icon(Icons.close, size: 18, color: AppColors.textHint),
|
||||
),
|
||||
]),
|
||||
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: AppColors.warning,
|
||||
),
|
||||
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}) {
|
||||
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)),
|
||||
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: AppColors.primary),
|
||||
),
|
||||
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);
|
||||
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),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFFFE8D0)),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
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: 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: 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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(children: [
|
||||
SizedBox(
|
||||
width: 56, height: 56,
|
||||
child: Stack(alignment: Alignment.center, children: [
|
||||
SizedBox(width: 56, height: 56, child: CircularProgressIndicator(value: (totalCal / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: const Color(0xFFFFE8D0), color: _dietAccent)),
|
||||
Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('$totalCal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: _dietAccent)),
|
||||
const Text('kcal', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('本餐热量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
_macro('碳水', 0.55, const Color(0xFFF5A623)),
|
||||
const SizedBox(width: 8),
|
||||
_macro('蛋白', 0.25, const Color(0xFF4A90D9)),
|
||||
const SizedBox(width: 8),
|
||||
_macro('脂肪', 0.20, const Color(0xFFE8686A)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _macro(String label, double ratio, Color color) {
|
||||
return Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 4), Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary))]),
|
||||
const SizedBox(height: 3),
|
||||
ClipRRect(borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: AppColors.borderLight, color: color)),
|
||||
]),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -448,17 +831,33 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBF5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFFFE8C0)),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -472,14 +871,21 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
try {
|
||||
await ref.read(dietProvider.notifier).saveRecord();
|
||||
if (mounted) popRoute(ref);
|
||||
} catch (e) { debugPrint('[Diet] 保存记录失败: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('[Diet] 保存记录失败: $e');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check_circle_outline, size: 22),
|
||||
label: const Text('保存记录', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
label: const Text(
|
||||
'保存记录',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _dietAccent,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user