feat: 实现站内通知系统
- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
- GET /api/v1/notifications (列表+游标分页)
- GET /api/v1/notifications/unread-count
- PATCH /api/v1/notifications/{id}/read (幂等)
- PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../data/models/notification_item.dart';
|
||||
import '../../data/models/notification_payload.dart';
|
||||
import '../../data/repositories/notification_repository.dart';
|
||||
import '../bloc/notification_bloc.dart';
|
||||
import '../widgets/notification_list_item.dart';
|
||||
|
||||
class NotificationCenterScreen extends StatefulWidget {
|
||||
const NotificationCenterScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
this.onNavigateToRoute,
|
||||
this.onOpenUrl,
|
||||
});
|
||||
|
||||
final NotificationRepository repository;
|
||||
final void Function(String route, {String? entityId, String? tab})?
|
||||
onNavigateToRoute;
|
||||
final void Function(String url)? onOpenUrl;
|
||||
|
||||
@override
|
||||
State<NotificationCenterScreen> createState() =>
|
||||
_NotificationCenterScreenState();
|
||||
}
|
||||
|
||||
class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
late NotificationBloc _bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = NotificationBloc(repository: widget.repository);
|
||||
_bloc.handleEvent(LoadNotifications());
|
||||
_bloc.addListener(_onStateChanged);
|
||||
}
|
||||
|
||||
void _onStateChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.removeListener(_onStateChanged);
|
||||
_bloc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final state = _bloc.state;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('通知'),
|
||||
actions: [
|
||||
if (state.items.any((item) => !item.isRead))
|
||||
TextButton(
|
||||
onPressed: _onMarkAllRead,
|
||||
child: Text('全部已读', style: TextStyle(color: colors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => _bloc.handleEvent(RefreshNotifications()),
|
||||
child: _buildBody(state, colors),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(NotificationState state, ColorScheme colors) {
|
||||
if (state.status == NotificationStatus.loading && state.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == NotificationStatus.error && state.items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: colors.error),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text('加载失败', style: TextStyle(color: colors.onSurfaceVariant)),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
FilledButton(
|
||||
onPressed: () => _bloc.handleEvent(LoadNotifications()),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.items.isEmpty) {
|
||||
return ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_none_outlined,
|
||||
size: 64,
|
||||
color: colors.outline,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
'暂无通知',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.items.length + (state.hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.items.length && state.hasMore) {
|
||||
_bloc.handleEvent(LoadMoreNotifications());
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final item = state.items[index];
|
||||
return NotificationListItem(
|
||||
item: item,
|
||||
onTap: () => _handleNotificationTap(item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleNotificationTap(NotificationItem item) {
|
||||
if (!item.isRead) {
|
||||
_bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
|
||||
}
|
||||
_executePayload(item.payload);
|
||||
}
|
||||
|
||||
void _executePayload(NotificationPayload payload) {
|
||||
switch (payload) {
|
||||
case NotificationPayloadNone():
|
||||
break;
|
||||
case NotificationPayloadRoute(:final route, :final entityId, :final tab):
|
||||
widget.onNavigateToRoute?.call(route, entityId: entityId, tab: tab);
|
||||
case NotificationPayloadUrl(:final url):
|
||||
widget.onOpenUrl?.call(url);
|
||||
}
|
||||
}
|
||||
|
||||
void _onMarkAllRead() {
|
||||
_bloc.handleEvent(MarkAllNotificationsRead());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user