Files
eryao/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart
T
qzl 1e22f27de2 feat: integrate invite API and improve notification handling
- Add invite code display and binding functionality via API
- Fix notification unread count sync on auth state change
- Improve notification mark read with server state validation
- Add auth state listener to trigger notification refresh
- Add YaoCoinConverter for coin-to-yao type conversion
- Remove YaoLegend from divination screens (UI cleanup)
- Abbreviate relation labels in yao detail view
- Add re-register notice to account delete screen
- Update 'coins' terminology to 'points' in localization
- Fix backend points consumption to only run in CHAT mode
- Add HttpxAuthNoiseFilter to suppress auth endpoint logging
- Fix notification static_schema import path
- Add test coverage for notification bloc error handling
- Update AGENTS.md page header rules and image handling
- Delete deprecated run-dev.sh script
2026-04-13 14:52:22 +08:00

318 lines
9.0 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 idx = _state.items.indexWhere((item) => item.id == notificationId);
if (idx == -1) return;
if (_state.items[idx].isRead) return;
_logger.info(
message: 'Mark notification read started',
extra: {'notification_id': notificationId},
);
try {
final updated = await _repository.markRead(
notificationId: notificationId,
);
final targetIndex = _state.items.indexWhere(
(item) => item.id == updated.id,
);
if (targetIndex == -1) {
return;
}
_state = _state.copyWith(
items: [
..._state.items.sublist(0, targetIndex),
updated,
..._state.items.sublist(targetIndex + 1),
],
unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0,
);
notifyListeners();
_logger.info(
message: 'Mark notification read succeeded',
extra: {'notification_id': notificationId},
);
} catch (error, stackTrace) {
_logger.error(
message: 'Mark read failed: ${error.runtimeType}',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _markAllRead() async {
_logger.info(message: 'Mark all notifications read started');
try {
await _repository.markAllRead();
_state = _state.copyWith(
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
unreadCount: 0,
);
notifyListeners();
_logger.info(message: 'Mark all notifications read succeeded');
} catch (error, stackTrace) {
_logger.error(
message: 'Mark all read failed: ${error.runtimeType}',
error: error,
stackTrace: stackTrace,
);
}
}
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();
}
}