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

@@ -3,8 +3,21 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'local_database.dart';
/// API 基础地址
const String baseUrl = 'http://localhost:5000'; // adb reverse 或同WiFi直连时改为PC的IP
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://localhost:5000',
);
class ApiException implements Exception {
final int? code;
final String message;
const ApiException(this.message, {this.code});
@override
String toString() => message;
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient {
@@ -40,22 +53,22 @@ class ApiClient {
/// 带 token 的 GET 请求
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
return _dio.get(path, queryParameters: queryParameters);
return _request(_dio.get(path, queryParameters: queryParameters));
}
/// 带 token 的 POST 请求
Future<Response> post(String path, {dynamic data}) async {
return _dio.post(path, data: data);
return _request(_dio.post(path, data: data));
}
/// 带 token 的 PUT 请求
Future<Response> put(String path, {dynamic data}) async {
return _dio.put(path, data: data);
return _request(_dio.put(path, data: data));
}
/// 带 token 的 DELETE 请求
Future<Response> delete(String path) async {
return _dio.delete(path);
return _request(_dio.delete(path));
}
/// 上传文件multipart返回文件 URL
@@ -63,7 +76,7 @@ class ApiClient {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
});
final res = await _dio.post(path, data: formData);
final res = await _request(_dio.post(path, data: formData));
final data = res.data;
if (data is Map) {
final topUrl = data['url']?.toString();
@@ -82,6 +95,46 @@ class ApiClient {
}
return null;
}
Response _ensureBusinessSuccess(Response response) {
final body = response.data;
if (body is Map && body.containsKey('code')) {
final rawCode = body['code'];
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
if (code != null && code != 0) {
throw ApiException(
body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: '请求失败',
code: code,
);
}
}
return response;
}
Future<Response> _request(Future<Response> request) async {
try {
return _ensureBusinessSuccess(await request);
} on DioException catch (error) {
final body = error.response?.data;
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null;
throw ApiException(
message,
code: rawCode is int
? rawCode
: int.tryParse('$rawCode') ?? error.response?.statusCode,
);
}
}
}
/// 认证拦截器:自动注入 token + 401 刷新

View File

@@ -12,6 +12,7 @@ import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/profile/profile_page.dart';
import '../pages/diet/diet_capture_page.dart';
import '../pages/device/device_scan_page.dart';
@@ -97,6 +98,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const SettingsPage();
case 'notificationPrefs':
return const NotificationPrefsPage();
case 'notifications':
return const NotificationCenterPage();
case 'staticText':
return StaticTextPage(type: params['type']!);
case 'dietDetail':

View File

@@ -121,7 +121,17 @@ class DoctorPatientDetailPage extends ConsumerWidget {
const SizedBox(height: 8),
if (archive['diagnosis'] != null)
_row('诊断', archive['diagnosis']),
if (archive['surgeryType'] != null)
if (archive['surgeries'] is List &&
(archive['surgeries'] as List).isNotEmpty)
_row(
'手术史',
(archive['surgeries'] as List).map((item) {
final surgery = item as Map;
final date = surgery['date']?.toString() ?? '';
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
}).join(''),
)
else if (archive['surgeryType'] != null)
_row(
'手术史',
'${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}',

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 &&

View File

@@ -0,0 +1,593 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../services/in_app_notification_service.dart';
class NotificationCenterPage extends ConsumerStatefulWidget {
const NotificationCenterPage({super.key});
@override
ConsumerState<NotificationCenterPage> createState() =>
_NotificationCenterPageState();
}
class _NotificationCenterPageState
extends ConsumerState<NotificationCenterPage> {
InAppNotificationHistory? _history;
bool _loading = true;
bool _markingAll = false;
String? _error;
InAppNotificationService get _service =>
ref.read(inAppNotificationServiceProvider);
@override
void initState() {
super.initState();
Future.microtask(_load);
}
Future<void> _load() async {
if (mounted) {
setState(() {
_loading = true;
_error = null;
});
}
try {
final history = await _service.getHistory();
if (!mounted) return;
setState(() {
_history = history;
_loading = false;
});
ref.invalidate(notificationUnreadCountProvider);
} catch (error) {
if (mounted) {
setState(() {
_error = '$error';
_loading = false;
});
}
}
}
Future<void> _open(InAppNotification item) async {
if (!item.isRead) {
await _service.acknowledge(item.id);
if (!mounted) return;
setState(() {
final current = _history;
if (current == null) return;
_history = InAppNotificationHistory(
unreadCount: (current.unreadCount - 1).clamp(0, current.unreadCount),
items: current.items
.map(
(value) =>
value.id == item.id ? value.copyWith(isRead: true) : value,
)
.toList(),
);
});
ref.invalidate(notificationUnreadCountProvider);
}
if (!mounted || item.actionType == null) return;
switch (item.actionType) {
case 'medication':
pushRoute(ref, 'medCheckIn', params: {'id': item.actionTargetId ?? ''});
return;
case 'exercise':
pushRoute(ref, 'exercisePlan');
return;
case 'health':
pushRoute(ref, 'trend', params: {'type': item.actionTargetId ?? ''});
return;
case 'report':
final reportId = item.actionTargetId;
if (reportId != null && reportId.isNotEmpty) {
pushRoute(ref, 'aiAnalysis', params: {'id': reportId});
}
return;
}
}
Future<void> _markAllRead() async {
if (_markingAll || (_history?.unreadCount ?? 0) == 0) return;
setState(() => _markingAll = true);
try {
await _service.markAllRead();
if (!mounted) return;
final current = _history;
if (current != null) {
setState(
() => _history = InAppNotificationHistory(
unreadCount: 0,
items: current.items
.map((item) => item.copyWith(isRead: true))
.toList(),
),
);
}
ref.invalidate(notificationUnreadCountProvider);
} finally {
if (mounted) setState(() => _markingAll = false);
}
}
Future<void> _delete(InAppNotification item) async {
try {
await _service.delete(item.id);
if (!mounted) return;
final current = _history;
if (current != null) {
setState(
() => _history = InAppNotificationHistory(
unreadCount: item.isRead
? current.unreadCount
: (current.unreadCount - 1).clamp(0, current.unreadCount),
items: current.items.where((value) => value.id != item.id).toList(),
),
);
}
ref.invalidate(notificationUnreadCountProvider);
} catch (_) {
if (mounted) await _load();
}
}
@override
Widget build(BuildContext context) {
final items = _history?.items ?? const <InAppNotification>[];
return Scaffold(
body: AppBackground(
safeArea: true,
child: Column(
children: [
_Header(
unreadCount: _history?.unreadCount ?? 0,
markingAll: _markingAll,
onBack: () => popRoute(ref),
onMarkAllRead: _markAllRead,
),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
color: const Color(0xFF438CE1),
child: _buildBody(items),
),
),
],
),
),
);
}
Widget _buildBody(List<InAppNotification> items) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return _MessageState(
icon: LucideIcons.wifiOff,
title: '通知加载失败',
message: '请检查网络连接后重试',
buttonText: '重新加载',
onPressed: _load,
);
}
if (items.isEmpty) {
return const _MessageState(
icon: LucideIcons.bellOff,
title: '暂时没有通知',
message: '用药、运动、健康指标和报告消息会显示在这里',
);
}
final today = <InAppNotification>[];
final earlier = <InAppNotification>[];
final now = DateTime.now();
for (final item in items) {
final local = item.createdAt.toLocal();
final isToday =
local.year == now.year &&
local.month == now.month &&
local.day == now.day;
(isToday ? today : earlier).add(item);
}
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 32),
children: [
if (today.isNotEmpty) ...[
const _SectionTitle('今天'),
...today.map(_buildDismissible),
],
if (earlier.isNotEmpty) ...[
const SizedBox(height: 10),
const _SectionTitle('更早'),
...earlier.map(_buildDismissible),
],
],
);
}
Widget _buildDismissible(InAppNotification item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(18),
),
child: const Icon(LucideIcons.trash2, color: Colors.white),
),
onDismissed: (_) => _delete(item),
child: _NotificationCard(item: item, onTap: () => _open(item)),
),
);
}
class _Header extends StatelessWidget {
final int unreadCount;
final bool markingAll;
final VoidCallback onBack;
final VoidCallback onMarkAllRead;
const _Header({
required this.unreadCount,
required this.markingAll,
required this.onBack,
required this.onMarkAllRead,
});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 12, 10),
child: Row(
children: [
IconButton(
tooltip: '返回',
onPressed: onBack,
icon: const Icon(LucideIcons.chevronLeft),
),
const SizedBox(width: 2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'通知中心',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
if (unreadCount > 0)
TextButton.icon(
onPressed: markingAll ? null : onMarkAllRead,
icon: markingAll
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(LucideIcons.checkCheck, size: 17),
label: const Text('全部已读'),
),
],
),
);
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 4, 10),
child: Text(
text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
);
}
class _NotificationCard extends StatelessWidget {
final InAppNotification item;
final VoidCallback onTap;
const _NotificationCard({required this.item, required this.onTap});
@override
Widget build(BuildContext context) {
final visual = _NotificationVisual.of(item);
return Material(
color: item.isRead
? Colors.white.withValues(alpha: 0.72)
: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(18),
elevation: item.isRead ? 0 : 1,
shadowColor: visual.color.withValues(alpha: 0.12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: item.isRead
? const Color(0xFFE9EEF5)
: visual.color.withValues(alpha: 0.18),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [visual.lightColor, Colors.white],
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(visual.icon, size: 22, color: visual.color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.title,
style: TextStyle(
fontSize: 15,
fontWeight: item.isRead
? FontWeight.w600
: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
if (!item.isRead)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: visual.color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: visual.color.withValues(alpha: 0.3),
blurRadius: 5,
),
],
),
),
],
),
const SizedBox(height: 5),
Text(
item.message,
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 9),
Row(
children: [
Text(
_formatTime(item.createdAt),
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
),
),
const Spacer(),
if (item.actionType != null)
Row(
children: [
Text(
'查看详情',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: visual.color,
),
),
const SizedBox(width: 2),
Icon(
LucideIcons.chevronRight,
size: 14,
color: visual.color,
),
],
),
],
),
],
),
),
],
),
),
),
);
}
static String _formatTime(DateTime value) {
final local = value.toLocal();
final now = DateTime.now();
final time =
'${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return time;
}
if (local.year == now.year) return '${local.month}${local.day}$time';
return '${local.year}${local.month}${local.day}$time';
}
}
class _NotificationVisual {
final IconData icon;
final Color color;
final Color lightColor;
const _NotificationVisual(this.icon, this.color, this.lightColor);
factory _NotificationVisual.of(InAppNotification item) {
switch (item.actionType) {
case 'exercise':
return const _NotificationVisual(
LucideIcons.activity,
Color(0xFF2C9A70),
Color(0xFFDDF7EC),
);
case 'health':
if (item.severity == 'critical') {
return const _NotificationVisual(
LucideIcons.triangleAlert,
Color(0xFFE55353),
Color(0xFFFFE7E7),
);
}
return const _NotificationVisual(
LucideIcons.heartPulse,
Color(0xFFE8863A),
Color(0xFFFFEEDC),
);
case 'report':
if (item.severity == 'error') {
return const _NotificationVisual(
LucideIcons.fileWarning,
Color(0xFFE55353),
Color(0xFFFFE7E7),
);
}
return const _NotificationVisual(
LucideIcons.fileCheck2,
Color(0xFF7B68CE),
Color(0xFFEDE9FF),
);
default:
return const _NotificationVisual(
LucideIcons.pill,
Color(0xFF468AD8),
Color(0xFFE4F0FF),
);
}
}
}
class _MessageState extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final String? buttonText;
final VoidCallback? onPressed;
const _MessageState({
required this.icon,
required this.title,
required this.message,
this.buttonText,
this.onPressed,
});
@override
Widget build(BuildContext context) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0.95),
const Color(0xFFF1F7FF).withValues(alpha: 0.9),
],
),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFE5EDF7)),
),
child: Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: const Color(0xFFE5F0FF),
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)),
),
const SizedBox(height: 18),
Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 7),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
height: 1.5,
color: AppColors.textSecondary,
),
),
if (buttonText != null) ...[
const SizedBox(height: 20),
FilledButton(onPressed: onPressed, child: Text(buttonText!)),
],
],
),
),
],
);
}

View File

@@ -85,6 +85,8 @@ class AuthNotifier extends Notifier<AuthState> {
}
}
Future<void> refreshProfile() => _loadProfile();
/// 发送验证码
Future<({String? error, String? devCode})> sendSms(String phone) async {
try {

View File

@@ -119,7 +119,9 @@ class ChatNotifier extends Notifier<ChatState> {
msgs[i].confirmed = true;
state = state.copyWith(messages: msgs);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(latestHealthProvider);
ref.invalidate(currentExercisePlanProvider);
return null;
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import '../services/health_service.dart';
import '../services/admin_service.dart';
import '../services/in_app_notification_service.dart';
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
return ExerciseService(ref.watch(apiClientProvider));
@@ -36,8 +37,25 @@ final adminServiceProvider = Provider<AdminService>((ref) {
return AdminService(ref.watch(apiClientProvider));
});
final inAppNotificationServiceProvider = Provider<InAppNotificationService>((
ref,
) {
return InAppNotificationService(ref.watch(apiClientProvider));
});
final notificationUnreadCountProvider = FutureProvider.autoDispose<int>((
ref,
) async {
final history = await ref
.watch(inAppNotificationServiceProvider)
.getHistory();
return history.unreadCount;
});
/// 最新健康数据 Provider
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final latestHealthProvider = FutureProvider.autoDispose<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(healthServiceProvider);
return service.getLatest();
});
@@ -49,48 +67,61 @@ void refreshHealthData(WidgetRef ref) {
}
/// 用药列表 Provider
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getList();
});
final medicationListProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getList();
});
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getReminders();
});
final medicationReminderProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getReminders();
});
/// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider);
return service.getDoctors().timeout(const Duration(seconds: 8));
});
/// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider);
return service.getQuota();
});
/// 当前运动计划 Provider
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final service = ref.watch(exerciseServiceProvider);
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
});
final currentExercisePlanProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
final service = ref.watch(exerciseServiceProvider);
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
});
/// 拍照/相册直接触发(无需跳转页面)
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new);
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(
CameraActionNotifier.new,
);
class CameraActionNotifier extends Notifier<String?> {
@override String? build() => null;
@override
String? build() => null;
void trigger(String action) => state = action;
void clear() => state = null;
}
/// 拍饮食动作触发(不用跳多余页面)
final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(DietActionNotifier.new);
final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(
DietActionNotifier.new,
);
class DietActionNotifier extends Notifier<String?> {
@override String? build() => null;
@override
String? build() => null;
void trigger(String action) => state = action;
void clear() => state = null;
}

View File

@@ -5,12 +5,22 @@ class InAppNotification {
final String type;
final String title;
final String message;
final String severity;
final String? actionType;
final String? actionTargetId;
final bool isRead;
final DateTime createdAt;
const InAppNotification({
required this.id,
required this.type,
required this.title,
required this.message,
required this.severity,
this.actionType,
this.actionTargetId,
required this.isRead,
required this.createdAt,
});
factory InAppNotification.fromJson(Map<String, dynamic> json) =>
@@ -19,7 +29,36 @@ class InAppNotification {
type: json['type']?.toString() ?? '',
title: json['title']?.toString() ?? '健康提醒',
message: json['message']?.toString() ?? '',
severity: json['severity']?.toString() ?? 'info',
actionType: json['actionType']?.toString(),
actionTargetId: json['actionTargetId']?.toString(),
isRead: json['isRead'] == true,
createdAt:
DateTime.tryParse(json['createdAt']?.toString() ?? '') ??
DateTime.now(),
);
InAppNotification copyWith({bool? isRead}) => InAppNotification(
id: id,
type: type,
title: title,
message: message,
severity: severity,
actionType: actionType,
actionTargetId: actionTargetId,
isRead: isRead ?? this.isRead,
createdAt: createdAt,
);
}
class InAppNotificationHistory {
final int unreadCount;
final List<InAppNotification> items;
const InAppNotificationHistory({
required this.unreadCount,
required this.items,
});
}
class InAppNotificationService {
@@ -32,7 +71,9 @@ class InAppNotificationService {
final items = response.data['data'] as List? ?? const [];
return items
.whereType<Map>()
.map((item) => InAppNotification.fromJson(Map<String, dynamic>.from(item)))
.map(
(item) => InAppNotification.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.id.isNotEmpty)
.toList();
}
@@ -40,4 +81,29 @@ class InAppNotificationService {
Future<void> acknowledge(String id) async {
await _api.post('/api/notifications/$id/acknowledge');
}
Future<void> markAllRead() async {
await _api.post('/api/notifications/read-all');
}
Future<InAppNotificationHistory> getHistory() async {
final response = await _api.get('/api/notifications');
final data = response.data['data'] as Map? ?? const {};
final rawItems = data['items'] as List? ?? const [];
return InAppNotificationHistory(
unreadCount: data['unreadCount'] as int? ?? 0,
items: rawItems
.whereType<Map>()
.map(
(item) =>
InAppNotification.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.id.isNotEmpty)
.toList(),
);
}
Future<void> delete(String id) async {
await _api.delete('/api/notifications/$id');
}
}