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);
|
||||
}
|
||||
}
|
||||
|
||||
156
health_app/lib/pages/diet/diet_nutrition_widgets.dart
Normal file
156
health_app/lib/pages/diet/diet_nutrition_widgets.dart
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user