feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化

- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤
- 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构
- 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画
- 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉
- 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
This commit is contained in:
MingNian
2026-07-13 10:39:34 +08:00
parent 335a3e6440
commit e654c1e0cc
19 changed files with 2140 additions and 343 deletions

View File

@@ -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);
}
}