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:
qzl
2026-04-10 18:50:08 +08:00
parent 17ef460391
commit 3f3d613d99
28 changed files with 3481 additions and 651 deletions
+15
View File
@@ -15,6 +15,9 @@ import '../features/auth/presentation/screens/login_screen.dart';
import '../features/divination/data/apis/divination_api.dart';
import '../features/divination/data/models/divination_result.dart';
import '../features/home/presentation/screens/home_screen.dart';
import '../features/notifications/data/apis/notification_api.dart';
import '../features/notifications/data/repositories/notification_repository.dart';
import '../features/notifications/presentation/bloc/notification_bloc.dart';
import '../features/settings/data/apis/profile_api.dart';
import '../features/settings/data/models/profile_settings.dart';
import '../l10n/app_localizations.dart';
@@ -35,6 +38,9 @@ class _EryaoAppState extends State<EryaoApp> {
late final AuthBloc _authBloc;
late final DivinationApi _divinationApi;
late final ProfileApi _profileApi;
late final NotificationApi _notificationApi;
late final NotificationRepository _notificationRepository;
late final NotificationBloc _notificationBloc;
Locale _locale = const Locale('zh');
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
@@ -61,6 +67,11 @@ class _EryaoAppState extends State<EryaoApp> {
final authApi = AuthApi(apiClient: apiClient);
_divinationApi = DivinationApi(apiClient: apiClient);
_profileApi = ProfileApi(apiClient: apiClient);
_notificationApi = NotificationApi(apiClient: apiClient);
_notificationRepository = NotificationRepositoryImpl(
notificationApi: _notificationApi,
);
_notificationBloc = NotificationBloc(repository: _notificationRepository);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
@@ -347,6 +358,7 @@ class _EryaoAppState extends State<EryaoApp> {
@override
void dispose() {
_authBloc.dispose();
_notificationBloc.dispose();
super.dispose();
}
@@ -415,6 +427,7 @@ class _EryaoAppState extends State<EryaoApp> {
_ensureCreditsLoaded(state.user!.email);
_ensureHistoryLoaded(state.user!.email);
_refreshProfile(userEmail: state.user!.email);
_notificationBloc.handleEvent(RefreshUnreadCount());
return HomeScreen(
account: state.user!.email,
sessionStore: _sessionStore,
@@ -423,6 +436,8 @@ class _EryaoAppState extends State<EryaoApp> {
historyRecords: _historyRecords,
coinBalance: _creditsBalance,
divinationApi: _divinationApi,
notificationBloc: _notificationBloc,
notificationRepository: _notificationRepository,
onLocaleChanged: _handleInterfaceLanguageChanged,
onProfileSettingsChanged: _saveProfileSettings,
onSaveProfile: _saveProfile,
@@ -8,6 +8,9 @@ import '../../../divination/presentation/screens/divination_result_screen.dart';
import '../../../divination/data/apis/divination_api.dart';
import '../../../divination/data/models/divination_params.dart';
import '../../../divination/data/models/divination_result.dart';
import '../../../notifications/data/repositories/notification_repository.dart';
import '../../../notifications/presentation/bloc/notification_bloc.dart';
import '../../../notifications/presentation/screens/notification_center_screen.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../../settings/presentation/screens/settings_screen.dart';
import '../../../../l10n/app_localizations.dart';
@@ -15,8 +18,6 @@ import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/bottom_nav_bar.dart';
import '../../../../shared/widgets/divination/divination_summary_card.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({
@@ -28,6 +29,8 @@ class HomeScreen extends StatefulWidget {
required this.historyRecords,
required this.coinBalance,
required this.divinationApi,
required this.notificationBloc,
required this.notificationRepository,
required this.onLocaleChanged,
required this.onProfileSettingsChanged,
required this.onSaveProfile,
@@ -45,6 +48,8 @@ class HomeScreen extends StatefulWidget {
final List<DivinationResultData> historyRecords;
final int coinBalance;
final DivinationApi divinationApi;
final NotificationBloc notificationBloc;
final NotificationRepository notificationRepository;
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@@ -108,6 +113,8 @@ class _HomeScreenState extends State<HomeScreen> {
onDivinationCompleted: widget.onDivinationCompleted,
onDeleteHistorySession: widget.onDeleteHistorySession,
allowVibration: widget.profileSettings.notification.allowVibration,
notificationBloc: widget.notificationBloc,
notificationRepository: widget.notificationRepository,
),
_ProfileTab(
account: widget.account,
@@ -155,6 +162,8 @@ class _HomeTab extends StatelessWidget {
required this.onDivinationCompleted,
required this.onDeleteHistorySession,
required this.allowVibration,
required this.notificationBloc,
required this.notificationRepository,
});
final List<DivinationResultData> historyItems;
@@ -165,6 +174,8 @@ class _HomeTab extends StatelessWidget {
onDivinationCompleted;
final Future<void> Function(String threadId) onDeleteHistorySession;
final bool allowVibration;
final NotificationBloc notificationBloc;
final NotificationRepository notificationRepository;
@override
Widget build(BuildContext context) {
@@ -194,16 +205,34 @@ class _HomeTab extends StatelessWidget {
),
IconButton(
onPressed: () {
Toast.show(
context,
l10n.featurePending,
type: ToastType.info,
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => NotificationCenterScreen(
repository: notificationRepository,
),
),
);
},
icon: Icon(
Icons.notifications,
color: colors.primary,
size: 28,
icon: ListenableBuilder(
listenable: notificationBloc,
builder: (context, _) {
final count = notificationBloc.state.unreadCount;
if (count > 0) {
return Badge(
label: Text(count > 99 ? '99+' : '$count'),
child: Icon(
Icons.notifications,
color: colors.primary,
size: 28,
),
);
}
return Icon(
Icons.notifications,
color: colors.primary,
size: 28,
);
},
),
tooltip: l10n.notify,
),
@@ -0,0 +1,113 @@
import 'package:dio/dio.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/notification_item.dart';
import '../models/notification_list_result.dart';
class NotificationApi {
NotificationApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
final Logger _logger = getLogger('features.notifications.data.apis');
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
}) async {
final queryParts = <String>['limit=$limit'];
if (cursor != null) {
queryParts.add('cursor=$cursor');
}
final path = '/api/v1/notifications?${queryParts.join("&")}';
try {
final json = await _apiClient.getJson(path);
final itemsJson = json['items'] as List<dynamic>? ?? [];
final items = itemsJson
.map((e) => parseNotificationItem(e as Map<String, dynamic>))
.toList();
return NotificationListResult(
items: items,
nextCursor: json['nextCursor'] as String?,
hasMore: json['hasMore'] as bool? ?? false,
);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'List notifications failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<int> getUnreadCount() async {
try {
final json = await _apiClient.getJson(
'/api/v1/notifications/unread-count',
);
return json['count'] as int? ?? 0;
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Get unread count failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<NotificationItem> markRead({required String notificationId}) async {
try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/$notificationId/read',
);
return parseNotificationItem(response.data!);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Mark read failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<int> markAllRead() async {
try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/mark-all-read',
);
return response.data?['updatedCount'] as int? ?? 0;
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Mark all read failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
if (data is Map<String, dynamic>) {
return ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
return ApiProblem(
status: status,
title: 'Network error',
detail: error.message ?? 'Request failed',
);
}
}
@@ -0,0 +1,39 @@
import 'notification_payload.dart';
class NotificationItem {
const NotificationItem({
required this.id,
required this.notificationId,
required this.type,
required this.title,
required this.body,
required this.payload,
required this.isRead,
required this.createdAt,
this.readAt,
});
final String id;
final String notificationId;
final String type;
final String title;
final String body;
final NotificationPayload payload;
final bool isRead;
final DateTime createdAt;
final DateTime? readAt;
NotificationItem copyWith({bool? isRead, DateTime? readAt}) {
return NotificationItem(
id: id,
notificationId: notificationId,
type: type,
title: title,
body: body,
payload: payload,
isRead: isRead ?? this.isRead,
createdAt: createdAt,
readAt: readAt ?? this.readAt,
);
}
}
@@ -0,0 +1,32 @@
import 'notification_payload.dart';
import 'notification_item.dart';
class NotificationListResult {
const NotificationListResult({
required this.items,
this.nextCursor,
required this.hasMore,
});
final List<NotificationItem> items;
final String? nextCursor;
final bool hasMore;
}
NotificationItem parseNotificationItem(Map<String, dynamic> json) {
return NotificationItem(
id: json['id'] as String,
notificationId: json['notificationId'] as String,
type: json['type'] as String,
title: json['title'] as String,
body: json['body'] as String,
payload: parseNotificationPayload(
(json['payload'] as Map<String, dynamic>?) ?? {},
),
isRead: json['isRead'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String),
readAt: json['readAt'] != null
? DateTime.parse(json['readAt'] as String)
: null,
);
}
@@ -0,0 +1,43 @@
sealed class NotificationPayload {
const NotificationPayload();
}
final class NotificationPayloadNone extends NotificationPayload {
const NotificationPayloadNone();
}
final class NotificationPayloadRoute extends NotificationPayload {
const NotificationPayloadRoute({
required this.route,
this.entityId,
this.tab,
});
final String route;
final String? entityId;
final String? tab;
}
final class NotificationPayloadUrl extends NotificationPayload {
const NotificationPayloadUrl({required this.url});
final String url;
}
NotificationPayload parseNotificationPayload(Map<String, dynamic> json) {
final action = json['action'];
switch (action) {
case 'open_route':
return NotificationPayloadRoute(
route: json['route'] as String? ?? '',
entityId: json['entityId'] as String?,
tab: json['tab'] as String?,
);
case 'open_url':
return NotificationPayloadUrl(url: json['url'] as String? ?? '');
case 'none':
return const NotificationPayloadNone();
default:
return const NotificationPayloadNone();
}
}
@@ -0,0 +1,46 @@
import '../../data/apis/notification_api.dart';
import '../../data/models/notification_item.dart';
import '../../data/models/notification_list_result.dart';
abstract class NotificationRepository {
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
});
Future<int> getUnreadCount();
Future<NotificationItem> markRead({required String notificationId});
Future<int> markAllRead();
}
class NotificationRepositoryImpl implements NotificationRepository {
NotificationRepositoryImpl({required NotificationApi notificationApi})
: _notificationApi = notificationApi;
final NotificationApi _notificationApi;
@override
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
}) async {
return _notificationApi.listNotifications(limit: limit, cursor: cursor);
}
@override
Future<int> getUnreadCount() async {
return _notificationApi.getUnreadCount();
}
@override
Future<NotificationItem> markRead({required String notificationId}) async {
return _notificationApi.markRead(notificationId: notificationId);
}
@override
Future<int> markAllRead() async {
return _notificationApi.markAllRead();
}
}
@@ -0,0 +1,311 @@
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();
}
}
@@ -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());
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../data/models/notification_item.dart';
class NotificationListItem extends StatelessWidget {
const NotificationListItem({
super.key,
required this.item,
required this.onTap,
});
final NotificationItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: item.isRead ? colors.surface : colors.surfaceContainerHighest,
border: Border(
bottom: BorderSide(
color: colors.outlineVariant.withValues(alpha: 0.3),
width: 0.5,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!item.isRead)
Container(
margin: const EdgeInsets.only(
top: AppSpacing.sm,
right: AppSpacing.sm,
),
width: 8,
height: 8,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: textTheme.bodyMedium?.copyWith(
fontWeight: item.isRead
? FontWeight.normal
: FontWeight.w600,
color: colors.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpacing.xs),
Text(
item.body,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpacing.xs),
Text(
_formatTime(item.createdAt),
style: textTheme.labelSmall?.copyWith(
color: colors.outline,
),
),
],
),
),
],
),
),
);
}
String _formatTime(DateTime dt) {
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
if (diff.inDays < 1) return '${diff.inHours}小时前';
if (diff.inDays < 30) return '${diff.inDays}天前';
return '${dt.month}/${dt.day}';
}
}