Files
eryao/apps/lib/features/notifications/presentation/screens/notification_center_screen.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

199 lines
5.8 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.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,
this.onUnreadCountChanged,
});
final NotificationRepository repository;
final void Function(String route, {String? entityId, String? tab})?
onNavigateToRoute;
final void Function(String url)? onOpenUrl;
final Future<void> Function()? onUnreadCountChanged;
@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('通知'),
centerTitle: true,
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(context, item),
);
},
);
}
Future<void> _handleNotificationTap(
BuildContext context,
NotificationItem item,
) async {
final wasUnread = !item.isRead;
if (!item.isRead) {
await _bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
final updatedIndex = _bloc.state.items.indexWhere((n) => n.id == item.id);
if (wasUnread &&
updatedIndex >= 0 &&
_bloc.state.items[updatedIndex].isRead) {
await widget.onUnreadCountChanged?.call();
}
}
if (context.mounted) {
await showNotificationDetailBottomSheet(
context: context,
item: item,
onMarkRead: () async {},
);
}
_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() {
unawaited(_markAllRead());
}
Future<void> _markAllRead() async {
final unreadBefore = _bloc.state.items.any((item) => !item.isRead);
await _bloc.handleEvent(MarkAllNotificationsRead());
final unreadAfter = _bloc.state.items.any((item) => !item.isRead);
if (unreadBefore && !unreadAfter) {
await widget.onUnreadCountChanged?.call();
}
}
}