1e22f27de2
- 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
199 lines
5.8 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|