3f3d613d99
- 后端: 新增 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 及错误码注册
312 lines
8.8 KiB
Dart
312 lines
8.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../../../core/logging/logger.dart';
|
|
import '../../data/models/notification_item.dart';
|
|
import '../../data/repositories/notification_repository.dart';
|
|
|
|
enum NotificationStatus { initial, loading, loaded, error }
|
|
|
|
class NotificationState {
|
|
const NotificationState({
|
|
this.status = NotificationStatus.initial,
|
|
this.items = const [],
|
|
this.unreadCount = 0,
|
|
this.hasMore = false,
|
|
this.nextCursor,
|
|
this.errorMessage,
|
|
});
|
|
|
|
final NotificationStatus status;
|
|
final List<NotificationItem> items;
|
|
final int unreadCount;
|
|
final bool hasMore;
|
|
final String? nextCursor;
|
|
final String? errorMessage;
|
|
|
|
NotificationState copyWith({
|
|
NotificationStatus? status,
|
|
List<NotificationItem>? items,
|
|
int? unreadCount,
|
|
bool? hasMore,
|
|
String? nextCursor,
|
|
String? errorMessage,
|
|
}) {
|
|
return NotificationState(
|
|
status: status ?? this.status,
|
|
items: items ?? this.items,
|
|
unreadCount: unreadCount ?? this.unreadCount,
|
|
hasMore: hasMore ?? this.hasMore,
|
|
nextCursor: nextCursor ?? this.nextCursor,
|
|
errorMessage: errorMessage ?? this.errorMessage,
|
|
);
|
|
}
|
|
}
|
|
|
|
sealed class NotificationEvent {}
|
|
|
|
final class LoadNotifications extends NotificationEvent {}
|
|
|
|
final class RefreshNotifications extends NotificationEvent {}
|
|
|
|
final class LoadMoreNotifications extends NotificationEvent {}
|
|
|
|
final class MarkNotificationRead extends NotificationEvent {
|
|
MarkNotificationRead({required this.notificationId});
|
|
final String notificationId;
|
|
}
|
|
|
|
final class MarkAllNotificationsRead extends NotificationEvent {}
|
|
|
|
final class RefreshUnreadCount extends NotificationEvent {}
|
|
|
|
final class NotificationCreatedEvent extends NotificationEvent {
|
|
NotificationCreatedEvent({required this.item});
|
|
final NotificationItem item;
|
|
}
|
|
|
|
final class NotificationReadUpdatedEvent extends NotificationEvent {
|
|
NotificationReadUpdatedEvent({
|
|
required this.notificationId,
|
|
required this.isRead,
|
|
});
|
|
final String notificationId;
|
|
final bool isRead;
|
|
}
|
|
|
|
final class NotificationRevokedEvent extends NotificationEvent {
|
|
NotificationRevokedEvent({required this.notificationId});
|
|
final String notificationId;
|
|
}
|
|
|
|
class NotificationBloc extends ChangeNotifier {
|
|
NotificationBloc({required NotificationRepository repository})
|
|
: _repository = repository;
|
|
|
|
final NotificationRepository _repository;
|
|
final Logger _logger = getLogger('features.notifications.bloc');
|
|
NotificationState _state = const NotificationState();
|
|
|
|
NotificationState get state => _state;
|
|
|
|
Future<void> handleEvent(NotificationEvent event) async {
|
|
switch (event) {
|
|
case LoadNotifications():
|
|
await _loadNotifications();
|
|
case RefreshNotifications():
|
|
await _refreshNotifications();
|
|
case LoadMoreNotifications():
|
|
await _loadMore();
|
|
case MarkNotificationRead():
|
|
await _markRead(event.notificationId);
|
|
case MarkAllNotificationsRead():
|
|
await _markAllRead();
|
|
case RefreshUnreadCount():
|
|
await _refreshUnreadCount();
|
|
case NotificationCreatedEvent():
|
|
_handleCreated(event.item);
|
|
case NotificationReadUpdatedEvent():
|
|
_handleReadUpdated(event.notificationId, event.isRead);
|
|
case NotificationRevokedEvent():
|
|
_handleRevoked(event.notificationId);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadNotifications() async {
|
|
if (_state.status == NotificationStatus.loading) return;
|
|
_state = _state.copyWith(status: NotificationStatus.loading);
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await _repository.listNotifications(limit: 20);
|
|
_state = _state.copyWith(
|
|
status: NotificationStatus.loaded,
|
|
items: result.items,
|
|
hasMore: result.hasMore,
|
|
nextCursor: result.nextCursor,
|
|
);
|
|
notifyListeners();
|
|
} catch (error, stackTrace) {
|
|
_logger.error(
|
|
message: 'Load notifications failed: ${error.runtimeType}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
_state = _state.copyWith(
|
|
status: NotificationStatus.error,
|
|
errorMessage: error.toString(),
|
|
);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshNotifications() async {
|
|
try {
|
|
final result = await _repository.listNotifications(limit: 20);
|
|
_state = _state.copyWith(
|
|
status: NotificationStatus.loaded,
|
|
items: result.items,
|
|
hasMore: result.hasMore,
|
|
nextCursor: result.nextCursor,
|
|
);
|
|
notifyListeners();
|
|
} catch (error, stackTrace) {
|
|
_logger.error(
|
|
message: 'Refresh notifications failed: ${error.runtimeType}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMore() async {
|
|
if (!_state.hasMore || _state.nextCursor == null) return;
|
|
|
|
try {
|
|
final result = await _repository.listNotifications(
|
|
limit: 20,
|
|
cursor: _state.nextCursor,
|
|
);
|
|
final allItems = [..._state.items, ...result.items];
|
|
_state = _state.copyWith(
|
|
items: allItems,
|
|
hasMore: result.hasMore,
|
|
nextCursor: result.nextCursor,
|
|
);
|
|
notifyListeners();
|
|
} catch (error, stackTrace) {
|
|
_logger.error(
|
|
message: 'Load more notifications failed: ${error.runtimeType}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _markRead(String notificationId) async {
|
|
final previousItems = _state.items;
|
|
final previousCount = _state.unreadCount;
|
|
final idx = _state.items.indexWhere((item) => item.id == notificationId);
|
|
if (idx == -1) return;
|
|
|
|
final wasUnread = !_state.items[idx].isRead;
|
|
_state = _state.copyWith(
|
|
items: [
|
|
..._state.items.sublist(0, idx),
|
|
_state.items[idx].copyWith(isRead: true),
|
|
..._state.items.sublist(idx + 1),
|
|
],
|
|
unreadCount: wasUnread
|
|
? (_state.unreadCount > 0 ? _state.unreadCount - 1 : 0)
|
|
: _state.unreadCount,
|
|
);
|
|
notifyListeners();
|
|
|
|
try {
|
|
await _repository.markRead(notificationId: notificationId);
|
|
} catch (error, stackTrace) {
|
|
_logger.error(
|
|
message: 'Mark read failed: ${error.runtimeType}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
_state = _state.copyWith(
|
|
items: previousItems,
|
|
unreadCount: previousCount,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> _markAllRead() async {
|
|
final previousItems = _state.items;
|
|
_state = _state.copyWith(
|
|
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
|
|
unreadCount: 0,
|
|
);
|
|
notifyListeners();
|
|
|
|
try {
|
|
await _repository.markAllRead();
|
|
} catch (error, stackTrace) {
|
|
_logger.error(
|
|
message: 'Mark all read failed: ${error.runtimeType}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
_state = _state.copyWith(items: previousItems);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshUnreadCount() async {
|
|
try {
|
|
final count = await _repository.getUnreadCount();
|
|
_state = _state.copyWith(unreadCount: count);
|
|
notifyListeners();
|
|
} catch (error, stackTrace) {
|
|
_logger.error(
|
|
message: 'Refresh unread count failed: ${error.runtimeType}',
|
|
error: error,
|
|
stackTrace: stackTrace,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleCreated(NotificationItem item) {
|
|
final exists = _state.items.any((i) => i.id == item.id);
|
|
if (exists) return;
|
|
_state = _state.copyWith(
|
|
items: [item, ..._state.items],
|
|
unreadCount: _state.unreadCount + (item.isRead ? 0 : 1),
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
void _handleReadUpdated(String notificationId, bool isRead) {
|
|
final idx = _state.items.indexWhere((item) => item.id == notificationId);
|
|
if (idx == -1) return;
|
|
final wasUnread = !_state.items[idx].isRead;
|
|
final nowRead = isRead;
|
|
|
|
_state = _state.copyWith(
|
|
items: [
|
|
..._state.items.sublist(0, idx),
|
|
_state.items[idx].copyWith(isRead: nowRead),
|
|
..._state.items.sublist(idx + 1),
|
|
],
|
|
);
|
|
|
|
if (wasUnread && nowRead) {
|
|
_state = _state.copyWith(
|
|
unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0,
|
|
);
|
|
} else if (!wasUnread && !nowRead) {
|
|
_state = _state.copyWith(unreadCount: _state.unreadCount + 1);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void _handleRevoked(String notificationId) {
|
|
final matchingItems = _state.items.where(
|
|
(i) => i.notificationId == notificationId,
|
|
);
|
|
if (matchingItems.isEmpty) return;
|
|
|
|
final item = matchingItems.first;
|
|
final wasUnread = !item.isRead;
|
|
_state = _state.copyWith(
|
|
items: _state.items
|
|
.where((i) => i.notificationId != notificationId)
|
|
.toList(),
|
|
);
|
|
if (wasUnread) {
|
|
_state = _state.copyWith(
|
|
unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0,
|
|
);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
}
|