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

View File

@@ -0,0 +1,156 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
class DietPalette {
DietPalette._();
static const primary = Color(0xFF6476E8);
static const primarySoft = Color(0xFFEEF0FF);
static const protein = Color(0xFF38A77B);
static const carbs = Color(0xFF4E91D8);
static const fat = Color(0xFFE58A76);
static const calorie = Color(0xFF6D6FEA);
static const gradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF8FA7FF), primary],
);
}
class DietCalorieRing extends StatelessWidget {
final int calories;
final double size;
const DietCalorieRing({super.key, required this.calories, this.size = 116});
@override
Widget build(BuildContext context) {
final progress = (calories / 800).clamp(0.04, 1.0);
return Semantics(
label: '总热量 $calories 千卡',
child: SizedBox.square(
dimension: size,
child: CustomPaint(
painter: _CalorieRingPainter(progress),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$calories',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const Text(
'千卡',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
],
),
),
),
);
}
}
class DietMacroBar extends StatelessWidget {
final String label;
final int value;
final Color color;
final double reference;
const DietMacroBar({
super.key,
required this.label,
required this.value,
required this.color,
required this.reference,
});
@override
Widget build(BuildContext context) {
final progress = (value / reference).clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const Spacer(),
Text(
'$value g',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 5,
value: progress,
backgroundColor: color.withValues(alpha: 0.12),
valueColor: AlwaysStoppedAnimation(color),
),
),
],
);
}
}
class _CalorieRingPainter extends CustomPainter {
final double progress;
const _CalorieRingPainter(this.progress);
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.shortestSide / 2 - 8;
final track = Paint()
..color = DietPalette.primarySoft
..style = PaintingStyle.stroke
..strokeWidth = 9
..strokeCap = StrokeCap.round;
final progressPaint = Paint()
..shader = const LinearGradient(
colors: [Color(0xFF8FA7FF), DietPalette.calorie],
).createShader(Offset.zero & size)
..style = PaintingStyle.stroke
..strokeWidth = 9
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, track);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
math.pi * 2 * progress,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant _CalorieRingPainter oldDelegate) =>
oldDelegate.progress != progress;
}

View File

@@ -30,7 +30,6 @@ class _HomePageState extends ConsumerState<HomePage>
final _scaffoldKey = GlobalKey<ScaffoldState>();
double? _drawerDragStartX;
String? _pickedImagePath;
int _lastMsgCount = 0;
Timer? _notificationTimer;
@override
@@ -53,16 +52,6 @@ class _HomePageState extends ConsumerState<HomePage>
}
}
@override
void didChangeMetrics() {
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
if (!_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
@@ -89,7 +78,6 @@ class _HomePageState extends ConsumerState<HomePage>
@override
Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider);
final user = auth.user;
@@ -112,15 +100,17 @@ class _HomePageState extends ConsumerState<HomePage>
}
});
final currentCount = chatState.messages.length;
if (currentCount > _lastMsgCount) {
ref.listen<int>(chatProvider.select((state) => state.messages.length), (
previous,
current,
) {
if (current <= (previous ?? 0)) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollCtrl.hasClients) {
if (mounted && _scrollCtrl.hasClients) {
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
}
});
}
_lastMsgCount = currentCount;
});
return Scaffold(
key: _scaffoldKey,
@@ -136,10 +126,7 @@ class _HomePageState extends ConsumerState<HomePage>
child: Stack(
children: [
RepaintBoundary(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
child: _HomeMessages(scrollCtrl: _scrollCtrl),
),
Positioned(
left: 0,
@@ -592,6 +579,18 @@ class _HomePageState extends ConsumerState<HomePage>
}
}
class _HomeMessages extends ConsumerWidget {
final ScrollController scrollCtrl;
const _HomeMessages({required this.scrollCtrl});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(chatProvider.select((state) => state.messages));
return ChatMessagesView(scrollCtrl: scrollCtrl, messages: messages);
}
}
class _HeaderIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;

View File

@@ -71,7 +71,9 @@ class ChatMessagesView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatState = ref.watch(chatProvider);
final streaming = ref.watch(
chatProvider.select((state) => (state.isStreaming, state.thinkingText)),
);
if (messages.isEmpty) {
return Center(
@@ -107,10 +109,17 @@ class ChatMessagesView extends ConsumerWidget {
controller: scrollCtrl,
reverse: false,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
cacheExtent: 180,
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
return _buildMessageContent(context, ref, msg, chatState);
return _buildMessageContent(
context,
ref,
msg,
streaming.$1,
streaming.$2 ?? '',
);
},
);
}
@@ -121,7 +130,8 @@ class ChatMessagesView extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
ChatMessage msg,
ChatState chatState,
bool isStreaming,
String thinkingText,
) {
switch (msg.type) {
case MessageType.agentWelcome:
@@ -134,12 +144,10 @@ class ChatMessagesView extends ConsumerWidget {
case MessageType.dataConfirm:
return _buildDataConfirmCard(context, ref, msg);
default:
if (!msg.isUser &&
chatState.isStreaming &&
msg.content.trim().isEmpty) {
return _buildThinkingBubble(context, chatState.thinkingText);
if (!msg.isUser && isStreaming && msg.content.trim().isEmpty) {
return _buildThinkingBubble(context, thinkingText);
}
return _buildTextBubble(context, ref, msg, chatState);
return _buildTextBubble(context, ref, msg);
}
}
@@ -1048,7 +1056,6 @@ class ChatMessagesView extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
ChatMessage msg,
ChatState? chatState,
) {
final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] as String?;
@@ -1088,7 +1095,7 @@ class ChatMessagesView extends ConsumerWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : AppColors.cardBackground,
color: isUser ? const Color(0xFF5F56FF) : AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 18.6 : 3),
topRight: Radius.circular(isUser ? 3 : 18.6),

View File

@@ -11,6 +11,7 @@ import '../../widgets/app_future_view.dart';
import '../../widgets/app_status_badge.dart';
import '../../widgets/common_widgets.dart';
import '../../widgets/enterprise_widgets.dart';
import '../../widgets/app_toast.dart';
class MedicationListPage extends ConsumerStatefulWidget {
const MedicationListPage({super.key});
@@ -35,9 +36,16 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
}
Future<void> _delete(String id) async {
await ref.read(medicationServiceProvider).deleteMedication(id);
ref.invalidate(medicationListProvider);
_load();
try {
await ref.read(medicationServiceProvider).deleteMedication(id);
ref.invalidate(medicationListProvider);
_load();
if (mounted) AppToast.show(context, '已删除', type: AppToastType.success);
} catch (_) {
if (mounted) {
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
}
}
}
@override

View File

@@ -12,6 +12,7 @@ import '../widgets/common_widgets.dart';
import '../widgets/app_error_state.dart';
import '../widgets/app_future_view.dart';
import '../widgets/app_toast.dart';
import 'diet/diet_nutrition_widgets.dart';
import 'diet/diet_record_logic.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
@@ -44,9 +45,19 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
}
Future<void> _delete(String id) async {
await ref.read(dietServiceProvider).deleteRecord(id);
if (mounted) {
setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
final index = _data.indexWhere((d) => d['id']?.toString() == id);
if (index < 0) return;
final removed = _data[index];
setState(() => _data.removeAt(index));
try {
await ref.read(dietServiceProvider).deleteRecord(id);
if (mounted) {
AppToast.show(context, '已删除', type: AppToastType.success);
}
} catch (_) {
if (!mounted) return;
setState(() => _data.insert(index.clamp(0, _data.length), removed));
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
}
}
@@ -195,16 +206,16 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
gradient: isSel ? AppColors.dietGradient : null,
gradient: isSel ? DietPalette.gradient : null,
color: isSel
? null
: (isToday ? AppColors.dietLight : Colors.white),
: (isToday ? DietPalette.primarySoft : Colors.white),
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: isSel
? Colors.transparent
: (isToday
? AppColors.dietBorder
? const Color(0xFFC9D0FF)
: AppColors.borderLight),
),
boxShadow: isSel ? AppColors.cardShadowLight : null,
@@ -220,7 +231,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
: ['', '', '', '', '', '', ''][d.weekday -
1],
style: TextStyle(
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w700,
color: isSel ? Colors.white : AppColors.textHint,
),
@@ -240,7 +251,8 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Text(
'$cal',
style: TextStyle(
fontSize: 10,
fontSize: 11,
fontWeight: FontWeight.w600,
color: isSel ? Colors.white70 : AppColors.textHint,
),
),
@@ -280,6 +292,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
final id = record['id']?.toString() ?? '';
return _SwipeAction(
itemKey: ValueKey(id),
onEdit: () => _showEditDialog(id, cal),
onDelete: () => _delete(id),
child: Material(
@@ -296,10 +309,14 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.dietLight,
color: DietPalette.primarySoft,
borderRadius: AppRadius.smBorder,
),
child: Icon(mealIcon, size: 19, color: AppColors.diet),
child: Icon(
mealIcon,
size: 19,
color: DietPalette.primary,
),
),
const SizedBox(width: 12),
Expanded(
@@ -311,7 +328,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Text(
mealLabel,
style: const TextStyle(
fontSize: 15,
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
@@ -320,7 +337,8 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Text(
'$cal 千卡',
style: const TextStyle(
fontSize: 13,
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
@@ -332,7 +350,8 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
child: Text(
items.map((f) => f['name']).join(' · '),
style: const TextStyle(
fontSize: 13,
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textHint,
),
maxLines: 1,
@@ -412,7 +431,7 @@ class _DietDaySummary extends StatelessWidget {
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFF8DB), Color(0xFFFFFFFF)],
colors: [Color(0xFFF0F3FF), Color(0xFFFFFFFF)],
),
borderRadius: AppRadius.xlBorder,
boxShadow: AppColors.cardShadowLight,
@@ -447,7 +466,7 @@ class _DietDaySummary extends StatelessWidget {
width: 42,
height: 42,
decoration: BoxDecoration(
gradient: AppColors.dietGradient,
gradient: DietPalette.gradient,
borderRadius: AppRadius.mdBorder,
),
child: const Icon(
@@ -543,7 +562,7 @@ class _Toggle extends StatelessWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
gradient: a ? AppColors.dietGradient : null,
gradient: a ? DietPalette.gradient : null,
color: a ? null : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: a ? Colors.transparent : AppColors.border),
@@ -599,7 +618,7 @@ class _TrendChart extends StatelessWidget {
Container(
height: h,
decoration: BoxDecoration(
gradient: data[i] > 0 ? AppColors.dietGradient : null,
gradient: data[i] > 0 ? DietPalette.gradient : null,
color: data[i] > 0 ? null : AppColors.border,
borderRadius: BorderRadius.circular(2),
),
@@ -614,20 +633,25 @@ class _TrendChart extends StatelessWidget {
}
class _SwipeAction extends StatelessWidget {
final Key itemKey;
final Widget child;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _SwipeAction({
required this.itemKey,
required this.child,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext c) => Dismissible(
key: UniqueKey(),
key: itemKey,
direction: DismissDirection.endToStart,
confirmDismiss: (_) async {
return false;
confirmDismiss: (direction) async {
// 右滑编辑:不消除,弹回,编辑由 onUpdate 触发
if (direction == DismissDirection.startToEnd) return false;
// 左滑删除放行onDismissed 会触发 onDelete
return true;
},
background: Container(
decoration: BoxDecoration(
@@ -703,11 +727,7 @@ class DietRecordDetailPage extends ConsumerWidget {
Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 15),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFF9E8), Colors.white],
),
color: Colors.white,
borderRadius: AppRadius.xlBorder,
boxShadow: AppColors.cardShadowLight,
),
@@ -716,20 +736,6 @@ class DietRecordDetailPage extends ConsumerWidget {
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: AppColors.dietGradient,
borderRadius: AppRadius.mdBorder,
),
child: const Icon(
Icons.restaurant_menu,
size: 21,
color: Colors.white,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -752,25 +758,39 @@ class DietRecordDetailPage extends ConsumerWidget {
],
),
),
Text(
'$totalCal 千卡',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: Color(0xFF9A3412),
),
),
],
),
const SizedBox(height: 18),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_DietDetailMetric(label: '蛋白质', value: '$protein g'),
_DietDetailMetric(label: '碳水', value: '$carbs g'),
_DietDetailMetric(label: '脂肪', value: '$fat g'),
_DietDetailMetric(
label: '食物',
value: '${items.length}',
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,
),
],
),
),
],
),
@@ -783,14 +803,7 @@ class DietRecordDetailPage extends ConsumerWidget {
),
),
const SizedBox(height: 20),
const Text(
'识别结果',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const Text('识别结果', style: AppTextStyles.sectionTitle),
const SizedBox(height: 8),
ClipRRect(
borderRadius: AppRadius.xlBorder,
@@ -810,8 +823,8 @@ class DietRecordDetailPage extends ConsumerWidget {
child: Text(
items[i]['name']?.toString() ?? '',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
@@ -822,7 +835,7 @@ class DietRecordDetailPage extends ConsumerWidget {
items[i]['portion']?.toString() ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
@@ -834,9 +847,9 @@ class DietRecordDetailPage extends ConsumerWidget {
'${items[i]['calories'] ?? 0} 千卡',
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF9A3412),
fontSize: 16,
fontWeight: FontWeight.w700,
color: DietPalette.calorie,
),
),
),
@@ -865,35 +878,6 @@ class DietRecordDetailPage extends ConsumerWidget {
}
}
class _DietDetailMetric extends StatelessWidget {
final String label;
final String value;
const _DietDetailMetric({required this.label, required this.value});
@override
Widget build(BuildContext context) => Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textHint),
),
],
),
);
}
/// 运动计划列表(含打卡)
class ExercisePlanPage extends ConsumerStatefulWidget {
const ExercisePlanPage({super.key});

View File

@@ -10,11 +10,11 @@ import '../../providers/auth_provider.dart';
// 持久化到后端 /api/notification-prefs启动时自动拉取一次。
final notificationPrefsProvider =
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
NotifierProvider<NotificationPrefsNotifier, Map<String, dynamic>>(
NotificationPrefsNotifier.new,
);
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
class NotificationPrefsNotifier extends Notifier<Map<String, dynamic>> {
static const _defaults = {
'pushEnabled': true,
'medication': true,
@@ -22,8 +22,8 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
'followUp': true,
'aiReply': false,
'dndEnabled': false,
'dndStart': false,
'dndEnd': false,
'dndStartMinutes': 22 * 60,
'dndEndMinutes': 8 * 60,
// 健康录入提醒——总开关 + 5 子项
'healthRecord': true,
'healthRecord.bp': true,
@@ -34,7 +34,7 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
};
@override
Map<String, bool> build() {
Map<String, dynamic> build() {
Future.microtask(_loadFromBackend);
return {..._defaults};
}
@@ -47,6 +47,11 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
if (data == null) return;
state = {
...state,
'pushEnabled': (data['pushEnabled'] as bool?) ?? true,
'dndEnabled': (data['dndEnabled'] as bool?) ?? false,
'dndStartMinutes':
(data['dndStartMinutes'] as num?)?.toInt() ?? 22 * 60,
'dndEndMinutes': (data['dndEndMinutes'] as num?)?.toInt() ?? 8 * 60,
'medication': (data['medicationReminder'] as bool?) ?? true,
'healthAlert': (data['abnormalAlert'] as bool?) ?? true,
'followUp': (data['followUpReminder'] as bool?) ?? true,
@@ -87,6 +92,8 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
'healthRecord.glucose' => 'healthRecordReminderGlucose',
'healthRecord.spo2' => 'healthRecordReminderSpO2',
'healthRecord.weight' => 'healthRecordReminderWeight',
'pushEnabled' => 'pushEnabled',
'dndEnabled' => 'dndEnabled',
_ => null,
};
if (backendField == null) return;
@@ -99,12 +106,24 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
}
}
void setDndStart(TimeOfDay time) {
state = {...state, 'dndStart': true};
Future<void> setDndStart(TimeOfDay time) async {
await _setTime('dndStartMinutes', time.hour * 60 + time.minute);
}
void setDndEnd(TimeOfDay time) {
state = {...state, 'dndEnd': true};
Future<void> setDndEnd(TimeOfDay time) async {
await _setTime('dndEndMinutes', time.hour * 60 + time.minute);
}
Future<void> _setTime(String key, int minutes) async {
final previous = state[key];
state = {...state, key: minutes};
try {
await ref
.read(apiClientProvider)
.put('/api/notification-prefs', data: {key: minutes});
} catch (_) {
state = {...state, key: previous};
}
}
}
@@ -116,7 +135,11 @@ class NotificationPrefsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(notificationPrefsProvider);
final dndOn = prefs['dndEnabled'] ?? false;
final dndOn = prefs['dndEnabled'] as bool? ?? false;
final dndStart = prefs['dndStartMinutes'] as int? ?? 22 * 60;
final dndEnd = prefs['dndEndMinutes'] as int? ?? 8 * 60;
String formatMinutes(int value) =>
'${(value ~/ 60).toString().padLeft(2, '0')}:${(value % 60).toString().padLeft(2, '0')}';
return GradientScaffold(
appBar: AppBar(
@@ -278,7 +301,9 @@ class NotificationPrefsPage extends ConsumerWidget {
children: [
_SwitchTile(
title: '开启免打扰模式',
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
subtitle: dndOn
? '${formatMinutes(dndStart)} - ${formatMinutes(dndEnd)} 期间静音'
: '关闭后全天接收通知',
value: dndOn,
showDivider: dndOn,
onChanged: (v) => ref
@@ -298,13 +323,13 @@ class NotificationPrefsPage extends ConsumerWidget {
Expanded(
child: _TimeButton(
label: '开始',
time: '22:00',
time: formatMinutes(dndStart),
onTap: () async {
final picked = await showAppTimePicker(
context,
initialTime: const TimeOfDay(
hour: 22,
minute: 0,
initialTime: TimeOfDay(
hour: dndStart ~/ 60,
minute: dndStart % 60,
),
);
if (picked != null && context.mounted) {
@@ -328,13 +353,13 @@ class NotificationPrefsPage extends ConsumerWidget {
Expanded(
child: _TimeButton(
label: '结束',
time: '08:00',
time: formatMinutes(dndEnd),
onTap: () async {
final picked = await showAppTimePicker(
context,
initialTime: const TimeOfDay(
hour: 8,
minute: 0,
initialTime: TimeOfDay(
hour: dndEnd ~/ 60,
minute: dndEnd % 60,
),
);
if (picked != null && context.mounted) {