feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker - 通知中心页面及前端通知服务接入 - 健康指标异常、用药/运动提醒等事件统一产出站内通知 - 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整 - AppDbContext 注册通知相关实体
This commit is contained in:
@@ -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 刷新
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'] ?? ''}',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
593
health_app/lib/pages/notifications/notification_center_page.dart
Normal file
593
health_app/lib/pages/notifications/notification_center_page.dart
Normal 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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,8 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshProfile() => _loadProfile();
|
||||
|
||||
/// 发送验证码
|
||||
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user