feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker - 通知中心页面及前端通知服务接入 - 健康指标异常、用药/运动提醒等事件统一产出站内通知 - 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整 - AppDbContext 注册通知相关实体
This commit is contained in:
@@ -12,7 +12,6 @@ import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../widgets/health_drawer.dart';
|
||||
import '../../services/in_app_notification_service.dart';
|
||||
import '../diet/diet_capture_page.dart';
|
||||
import 'widgets/chat_messages_view.dart';
|
||||
|
||||
@@ -29,15 +28,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
String? _pickedImagePath;
|
||||
int _lastMsgCount = 0;
|
||||
Timer? _notificationTimer;
|
||||
bool _checkingNotifications = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications());
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||
);
|
||||
_notificationTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _checkNotifications(),
|
||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,52 +50,6 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkNotifications() async {
|
||||
if (!mounted || _checkingNotifications) return;
|
||||
if (!ref.read(authProvider).isLoggedIn) return;
|
||||
_checkingNotifications = true;
|
||||
try {
|
||||
final service = InAppNotificationService(ref.read(apiClientProvider));
|
||||
final notifications = await service.getPending();
|
||||
for (final notification in notifications) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
notification.type == 'ExerciseReminder'
|
||||
? Icons.directions_run_outlined
|
||||
: Icons.medication_outlined,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(notification.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
Text(notification.message),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await service.acknowledge(notification.id);
|
||||
}
|
||||
} catch (_) {
|
||||
// App 内提醒失败不阻断主页使用,下次轮询会重试。
|
||||
} finally {
|
||||
_checkingNotifications = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textCtrl.text.trim();
|
||||
final imagePath = _pickedImagePath;
|
||||
@@ -169,6 +123,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final name = (user?.name != null && user!.name!.isNotEmpty)
|
||||
? user.name
|
||||
: '用户';
|
||||
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
@@ -221,7 +176,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
),
|
||||
_HeaderIconButton(
|
||||
icon: LucideIcons.bell,
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
badgeCount: unreadCount,
|
||||
onTap: () => pushRoute(ref, 'notifications'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -565,22 +521,55 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
class _HeaderIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _HeaderIconButton({required this.icon, required this.onTap});
|
||||
final int badgeCount;
|
||||
const _HeaderIconButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.badgeCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.surfaceGradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Icon(icon, size: 20, color: AppColors.textPrimary),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.surfaceGradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Icon(icon, size: 20, color: AppColors.textPrimary),
|
||||
),
|
||||
if (badgeCount > 0)
|
||||
Positioned(
|
||||
right: -5,
|
||||
top: -5,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
badgeCount > 99 ? '99+' : '$badgeCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user