feat: 应用内通知系统 + 结构化手术史/用药等相关改动

- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker
- 通知中心页面及前端通知服务接入
- 健康指标异常、用药/运动提醒等事件统一产出站内通知
- 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整
- AppDbContext 注册通知相关实体
This commit is contained in:
MingNian
2026-06-21 21:06:29 +08:00
parent b57d0d16f4
commit 13714d9ed8
34 changed files with 1541 additions and 200 deletions

View File

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

View File

@@ -1464,13 +1464,13 @@ class ChatMessagesView extends ConsumerWidget {
? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'
: null;
final hr = healthData['HeartRate'];
final hrText = hr is int ? '$hr' : null;
final bs = healthData['BloodSugar'];
final bsText = bs is num ? '$bs' : null;
final bo = healthData['BloodOxygen'];
final boText = bo is num ? '$bo' : null;
final hrText = hr is Map && hr['value'] is num ? '${hr['value']}' : null;
final bs = healthData['Glucose'];
final bsText = bs is Map && bs['value'] is num ? '${bs['value']}' : null;
final bo = healthData['SpO2'];
final boText = bo is Map && bo['value'] is num ? '${bo['value']}' : null;
final wt = healthData['Weight'];
final wtText = wt is num ? '$wt' : null;
final wtText = wt is Map && wt['value'] is num ? '${wt['value']}' : null;
final allNull =
bpText == null &&
hrText == null &&