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
This commit is contained in:
@@ -185,58 +185,64 @@ class NotificationBloc extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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;
|
||||
if (_state.items[idx].isRead) 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,
|
||||
_logger.info(
|
||||
message: 'Mark notification read started',
|
||||
extra: {'notification_id': notificationId},
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _repository.markRead(notificationId: notificationId);
|
||||
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,
|
||||
);
|
||||
_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();
|
||||
_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,
|
||||
);
|
||||
_state = _state.copyWith(items: previousItems);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-4
@@ -1,6 +1,9 @@
|
||||
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';
|
||||
@@ -13,12 +16,14 @@ class NotificationCenterScreen extends StatefulWidget {
|
||||
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() =>
|
||||
@@ -55,6 +60,7 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('通知'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
if (state.items.any((item) => !item.isRead))
|
||||
TextButton(
|
||||
@@ -136,15 +142,32 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
final item = state.items[index];
|
||||
return NotificationListItem(
|
||||
item: item,
|
||||
onTap: () => _handleNotificationTap(item),
|
||||
onTap: () => _handleNotificationTap(context, item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleNotificationTap(NotificationItem item) {
|
||||
Future<void> _handleNotificationTap(
|
||||
BuildContext context,
|
||||
NotificationItem item,
|
||||
) async {
|
||||
final wasUnread = !item.isRead;
|
||||
if (!item.isRead) {
|
||||
_bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
|
||||
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);
|
||||
}
|
||||
@@ -161,6 +184,15 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
}
|
||||
|
||||
void _onMarkAllRead() {
|
||||
_bloc.handleEvent(MarkAllNotificationsRead());
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,73 +18,78 @@ class NotificationListItem extends StatelessWidget {
|
||||
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,
|
||||
return IntrinsicHeight(
|
||||
child: 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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user