feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化
- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤 - 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构 - 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画 - 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉 - 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
This commit is contained in:
@@ -9,6 +9,7 @@ import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import 'diet_nutrition_widgets.dart';
|
||||
import 'diet_record_logic.dart';
|
||||
|
||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(
|
||||
@@ -27,7 +28,7 @@ class DietState {
|
||||
DietState({
|
||||
this.imagePath,
|
||||
this.foods = const [],
|
||||
this.mealType = 'lunch',
|
||||
this.mealType = '',
|
||||
this.isAnalyzing = false,
|
||||
this.healthScore,
|
||||
this.errorMessage,
|
||||
@@ -271,6 +272,9 @@ class DietNotifier extends Notifier<DietState> {
|
||||
}
|
||||
|
||||
Future<void> saveRecord() async {
|
||||
if (state.mealType.isEmpty) {
|
||||
throw const DietFoodValidationException('请选择餐次');
|
||||
}
|
||||
final selectedFoods = state.foods.where((f) => f.selected).toList();
|
||||
if (selectedFoods.isEmpty) {
|
||||
throw const DietFoodValidationException('请至少选择一种食物');
|
||||
@@ -296,7 +300,7 @@ class DietNotifier extends Notifier<DietState> {
|
||||
await api.post(
|
||||
'/api/diet-records',
|
||||
data: {
|
||||
'mealType': mealMap[state.mealType] ?? 'Lunch',
|
||||
'mealType': mealMap[state.mealType]!,
|
||||
'totalCalories': totalCal,
|
||||
'healthScore': state.healthScore ?? 3,
|
||||
'recordedAt': DateTime.now().toIso8601String().substring(0, 10),
|
||||
@@ -318,9 +322,9 @@ class DietNotifier extends Notifier<DietState> {
|
||||
}
|
||||
|
||||
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
||||
const _dietAccent = AppColors.diet;
|
||||
const _dietKcalText = Color(0xFF9A3412);
|
||||
const _dietGradient = AppColors.dietGradient;
|
||||
const _dietAccent = DietPalette.primary;
|
||||
const _dietKcalText = DietPalette.calorie;
|
||||
const _dietGradient = DietPalette.gradient;
|
||||
|
||||
class DietCapturePage extends ConsumerStatefulWidget {
|
||||
const DietCapturePage({super.key});
|
||||
@@ -428,47 +432,47 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
gradient: _dietGradient,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.local_fire_department_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'本餐估算',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Text('本餐估算', style: AppTextStyles.sectionTitle),
|
||||
const Spacer(),
|
||||
_MealTypeMenu(
|
||||
_MealTypeSelector(
|
||||
value: state.mealType,
|
||||
onChanged: (type) =>
|
||||
ref.read(dietProvider.notifier).setMealType(type),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 13),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_MacroStat(label: '总热量', value: '$totalCal', unit: '千卡'),
|
||||
const SizedBox(width: 8),
|
||||
_MacroStat(label: '蛋白质', value: '$protein', unit: 'g'),
|
||||
const SizedBox(width: 8),
|
||||
_MacroStat(label: '碳水', value: '$carbs', unit: 'g'),
|
||||
const SizedBox(width: 8),
|
||||
_MacroStat(label: '脂肪', value: '$fat', unit: 'g'),
|
||||
DietCalorieRing(calories: totalCal),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
DietMacroBar(
|
||||
label: '蛋白质',
|
||||
value: protein,
|
||||
color: DietPalette.protein,
|
||||
reference: 50,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
DietMacroBar(
|
||||
label: '碳水',
|
||||
value: carbs,
|
||||
color: DietPalette.carbs,
|
||||
reference: 100,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
DietMacroBar(
|
||||
label: '脂肪',
|
||||
value: fat,
|
||||
color: DietPalette.fat,
|
||||
reference: 35,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -603,7 +607,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 12, 11),
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 12, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
@@ -615,12 +619,12 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: food.selected ? _dietGradient : null,
|
||||
color: food.selected ? null : AppColors.dietLight,
|
||||
color: food.selected ? null : DietPalette.primarySoft,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(
|
||||
color: food.selected
|
||||
? Colors.transparent
|
||||
: AppColors.dietBorder,
|
||||
: const Color(0xFFC9D0FF),
|
||||
),
|
||||
),
|
||||
child: food.selected
|
||||
@@ -638,8 +642,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
.updateFoodName(food.id, v),
|
||||
fieldKey: '${food.id}_name',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -655,7 +659,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
fieldKey: '${food.id}_portion',
|
||||
hint: '份量',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
@@ -663,7 +667,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
width: 96,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -677,8 +681,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
align: TextAlign.right,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _dietKcalText,
|
||||
),
|
||||
),
|
||||
@@ -686,7 +690,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
const Text(
|
||||
' 千卡',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
@@ -722,11 +726,18 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
textAlign: align,
|
||||
style: style ?? const TextStyle(fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
fillColor: Colors.transparent,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint),
|
||||
hintStyle: const TextStyle(fontSize: 16, color: AppColors.textHint),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -853,58 +864,39 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _MealTypeMenu extends StatelessWidget {
|
||||
class _MealTypeSelector extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _MealTypeMenu({required this.value, required this.onChanged});
|
||||
const _MealTypeSelector({required this.value, required this.onChanged});
|
||||
|
||||
static const _options = [
|
||||
(label: '早餐', value: 'breakfast'),
|
||||
(label: '午餐', value: 'lunch'),
|
||||
(label: '晚餐', value: 'dinner'),
|
||||
(label: '加餐', value: 'snack'),
|
||||
(label: '早餐', value: 'breakfast', icon: Icons.wb_sunny_outlined),
|
||||
(label: '午餐', value: 'lunch', icon: Icons.light_mode_outlined),
|
||||
(label: '晚餐', value: 'dinner', icon: Icons.nights_stay_outlined),
|
||||
(label: '加餐', value: 'snack', icon: Icons.cookie_outlined),
|
||||
];
|
||||
|
||||
String get _label {
|
||||
for (final option in _options) {
|
||||
if (option.value == value) return option.label;
|
||||
}
|
||||
return '午餐';
|
||||
return '餐次';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<String>(
|
||||
initialValue: value,
|
||||
onSelected: onChanged,
|
||||
color: Colors.white,
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorder),
|
||||
itemBuilder: (context) => [
|
||||
for (final option in _options)
|
||||
PopupMenuItem<String>(
|
||||
value: option.value,
|
||||
child: Text(
|
||||
option.label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: option.value == value
|
||||
? FontWeight.w900
|
||||
: FontWeight.w700,
|
||||
color: option.value == value
|
||||
? _dietKcalText
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
final selected = value.isNotEmpty;
|
||||
return GestureDetector(
|
||||
onTap: () => _showOptions(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.dietLight,
|
||||
color: selected ? DietPalette.primarySoft : Colors.white,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.dietBorder),
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFFC9D0FF) : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -913,79 +905,93 @@ class _MealTypeMenu extends StatelessWidget {
|
||||
_label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: _dietKcalText,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down_rounded,
|
||||
size: 18,
|
||||
color: _dietKcalText,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MacroStat extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final String unit;
|
||||
|
||||
const _MacroStat({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: _dietKcalText,
|
||||
Future<void> _showOptions(BuildContext context) async {
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('选择餐次', style: AppTextStyles.sectionTitle),
|
||||
const SizedBox(height: 12),
|
||||
for (var i = 0; i < _options.length; i++) ...[
|
||||
InkWell(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
onTap: () => Navigator.pop(sheetContext, _options[i].value),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: DietPalette.primarySoft,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(
|
||||
_options[i].icon,
|
||||
size: 20,
|
||||
color: DietPalette.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_options[i].label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_options[i].value == value)
|
||||
const Icon(
|
||||
Icons.check_rounded,
|
||||
size: 21,
|
||||
color: DietPalette.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textHint,
|
||||
if (i < _options.length - 1)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 50),
|
||||
child: Divider(height: 1, color: AppColors.divider),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected != null) onChanged(selected);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user