feat(notification): 通知标题和正文支持多语言

- 通知静态配置支持 title/body i18n
- 前端通知列表和详情页展示本地化内容
- 新增数据库迁移脚本
- 更新通知协议文档
This commit is contained in:
ZL-Q
2026-04-28 17:20:17 +08:00
parent b9617ae152
commit a940f2ea47
16 changed files with 601 additions and 213 deletions
@@ -15,15 +15,19 @@ class NotificationApi {
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
String locale = 'zh',
}) async {
final queryParts = <String>['limit=$limit'];
final queryParameters = <String, Object>{'limit': limit, 'locale': locale};
if (cursor != null) {
queryParts.add('cursor=$cursor');
queryParameters['cursor'] = cursor;
}
final path = '/api/v1/notifications?${queryParts.join("&")}';
try {
final json = await _apiClient.getJson(path);
final response = await _apiClient.rawDio.get<Map<String, dynamic>>(
'/api/v1/notifications',
queryParameters: queryParameters,
);
final json = response.data ?? <String, dynamic>{};
final itemsJson = json['items'] as List<dynamic>? ?? [];
final items = itemsJson
.map((e) => parseNotificationItem(e as Map<String, dynamic>))
@@ -59,21 +63,16 @@ class NotificationApi {
}
}
Future<NotificationItem> markRead({required String notificationId}) async {
_logger.info(
message: 'Mark read request started',
extra: {'notification_id': notificationId},
);
Future<NotificationItem> markRead({
required String notificationId,
String locale = 'zh',
}) async {
try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/$notificationId/read',
queryParameters: {'locale': locale},
);
final item = parseNotificationItem(response.data!);
_logger.info(
message: 'Mark read request succeeded',
extra: {'notification_id': notificationId, 'is_read': item.isRead},
);
return item;
return parseNotificationItem(response.data!);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Mark read failed',
@@ -85,17 +84,11 @@ class NotificationApi {
}
Future<int> markAllRead() async {
_logger.info(message: 'Mark all read request started');
try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/mark-all-read',
);
final updatedCount = response.data?['updatedCount'] as int? ?? 0;
_logger.info(
message: 'Mark all read request succeeded',
extra: {'updated_count': updatedCount},
);
return updatedCount;
return response.data?['updatedCount'] as int? ?? 0;
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Mark all read failed',
@@ -6,11 +6,15 @@ abstract class NotificationRepository {
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
String locale = 'zh',
});
Future<int> getUnreadCount();
Future<NotificationItem> markRead({required String notificationId});
Future<NotificationItem> markRead({
required String notificationId,
String locale = 'zh',
});
Future<int> markAllRead();
}
@@ -25,8 +29,13 @@ class NotificationRepositoryImpl implements NotificationRepository {
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
String locale = 'zh',
}) async {
return _notificationApi.listNotifications(limit: limit, cursor: cursor);
return _notificationApi.listNotifications(
limit: limit,
cursor: cursor,
locale: locale,
);
}
@override
@@ -35,8 +44,14 @@ class NotificationRepositoryImpl implements NotificationRepository {
}
@override
Future<NotificationItem> markRead({required String notificationId}) async {
return _notificationApi.markRead(notificationId: notificationId);
Future<NotificationItem> markRead({
required String notificationId,
String locale = 'zh',
}) async {
return _notificationApi.markRead(
notificationId: notificationId,
locale: locale,
);
}
@override
@@ -15,6 +15,7 @@ class NotificationState {
this.unreadCount = 0,
this.hasMore = false,
this.nextCursor,
this.isLoadingMore = false,
this.errorMessage,
});
@@ -23,6 +24,7 @@ class NotificationState {
final int unreadCount;
final bool hasMore;
final String? nextCursor;
final bool isLoadingMore;
final String? errorMessage;
NotificationState copyWith({
@@ -31,6 +33,7 @@ class NotificationState {
int? unreadCount,
bool? hasMore,
String? nextCursor,
bool? isLoadingMore,
String? errorMessage,
}) {
return NotificationState(
@@ -39,6 +42,7 @@ class NotificationState {
unreadCount: unreadCount ?? this.unreadCount,
hasMore: hasMore ?? this.hasMore,
nextCursor: nextCursor ?? this.nextCursor,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@@ -81,10 +85,13 @@ final class NotificationRevokedEvent extends NotificationEvent {
}
class NotificationBloc extends ChangeNotifier {
NotificationBloc({required NotificationRepository repository})
: _repository = repository;
NotificationBloc({
required NotificationRepository repository,
this.locale = 'zh',
}) : _repository = repository;
final NotificationRepository _repository;
final String locale;
final Logger _logger = getLogger('features.notifications.bloc');
NotificationState _state = const NotificationState();
@@ -119,7 +126,10 @@ class NotificationBloc extends ChangeNotifier {
notifyListeners();
try {
final result = await _repository.listNotifications(limit: 20);
final result = await _repository.listNotifications(
limit: 20,
locale: locale,
);
_state = _state.copyWith(
status: NotificationStatus.loaded,
items: result.items,
@@ -143,7 +153,10 @@ class NotificationBloc extends ChangeNotifier {
Future<void> _refreshNotifications() async {
try {
final result = await _repository.listNotifications(limit: 20);
final result = await _repository.listNotifications(
limit: 20,
locale: locale,
);
_state = _state.copyWith(
status: NotificationStatus.loaded,
items: result.items,
@@ -161,18 +174,25 @@ class NotificationBloc extends ChangeNotifier {
}
Future<void> _loadMore() async {
if (!_state.hasMore || _state.nextCursor == null) return;
if (_state.isLoadingMore || !_state.hasMore || _state.nextCursor == null) {
return;
}
_state = _state.copyWith(isLoadingMore: true);
notifyListeners();
try {
final result = await _repository.listNotifications(
limit: 20,
cursor: _state.nextCursor,
locale: locale,
);
final allItems = [..._state.items, ...result.items];
_state = _state.copyWith(
items: allItems,
hasMore: result.hasMore,
nextCursor: result.nextCursor,
isLoadingMore: false,
);
notifyListeners();
} catch (error, stackTrace) {
@@ -181,6 +201,8 @@ class NotificationBloc extends ChangeNotifier {
error: error,
stackTrace: stackTrace,
);
_state = _state.copyWith(isLoadingMore: false);
notifyListeners();
}
}
@@ -197,6 +219,7 @@ class NotificationBloc extends ChangeNotifier {
try {
final updated = await _repository.markRead(
notificationId: notificationId,
locale: locale,
);
final targetIndex = _state.items.indexWhere(
(item) => item.id == updated.id,
@@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart';
import '../../data/models/notification_item.dart';
import '../../data/models/notification_payload.dart';
@@ -31,54 +33,93 @@ class NotificationCenterScreen extends StatefulWidget {
}
class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
late NotificationBloc _bloc;
NotificationBloc? _bloc;
late final ScrollController _scrollController;
String get _currentLocale {
final locale = Localizations.localeOf(context);
if (locale.scriptCode == 'Hant') return 'zh_Hant';
return locale.languageCode;
}
@override
void initState() {
super.initState();
_bloc = NotificationBloc(repository: widget.repository);
_bloc.handleEvent(LoadNotifications());
_bloc.addListener(_onStateChanged);
_scrollController = ScrollController()..addListener(_onScroll);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_bloc == null) {
_bloc = NotificationBloc(
repository: widget.repository,
locale: _currentLocale,
);
_bloc!.handleEvent(LoadNotifications());
_bloc!.addListener(_onStateChanged);
}
}
void _onStateChanged() {
setState(() {});
}
void _onScroll() {
if (!_scrollController.hasClients || _bloc == null) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 240) {
unawaited(_bloc!.handleEvent(LoadMoreNotifications()));
}
}
@override
void dispose() {
_bloc.removeListener(_onStateChanged);
_bloc.dispose();
_bloc?.removeListener(_onStateChanged);
_bloc?.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final state = _bloc.state;
final l10n = AppLocalizations.of(context)!;
final state = _bloc!.state;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: const Text('通知'),
title: Text(l10n.notifyCenterTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
actions: [
if (state.items.any((item) => !item.isRead))
TextButton(
onPressed: _onMarkAllRead,
child: Text('全部已读', style: TextStyle(color: colors.primary)),
child: Text(
l10n.notifyMarkAllRead,
style: TextStyle(color: colors.primary),
),
),
],
),
body: RefreshIndicator(
onRefresh: () => _bloc.handleEvent(RefreshNotifications()),
child: _buildBody(state, colors),
onRefresh: () => _bloc!.handleEvent(RefreshNotifications()),
child: _buildBody(state, colors, l10n),
),
);
}
Widget _buildBody(NotificationState state, ColorScheme colors) {
Widget _buildBody(
NotificationState state,
ColorScheme colors,
AppLocalizations l10n,
) {
if (state.status == NotificationStatus.loading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
return const Center(child: AppLoadingIndicator());
}
if (state.status == NotificationStatus.error && state.items.isEmpty) {
@@ -88,11 +129,14 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
children: [
Icon(Icons.error_outline, size: 48, color: colors.error),
const SizedBox(height: AppSpacing.md),
Text('加载失败', style: TextStyle(color: colors.onSurfaceVariant)),
Text(
l10n.notifyLoadFailed,
style: TextStyle(color: colors.onSurfaceVariant),
),
const SizedBox(height: AppSpacing.sm),
FilledButton(
onPressed: () => _bloc.handleEvent(LoadNotifications()),
child: const Text('重试'),
onPressed: () => _bloc!.handleEvent(LoadNotifications()),
child: Text(l10n.notifyRetry),
),
],
),
@@ -115,7 +159,7 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
),
const SizedBox(height: AppSpacing.md),
Text(
'暂无通知',
l10n.notifyEmpty,
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 16,
@@ -130,13 +174,13 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
}
return ListView.builder(
controller: _scrollController,
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()),
child: Center(child: AppLoadingIndicator()),
);
}
final item = state.items[index];
@@ -154,11 +198,13 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
) 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);
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) {
_bloc!.state.items[updatedIndex].isRead) {
await widget.onUnreadCountChanged?.call();
}
}
@@ -188,9 +234,9 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
}
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);
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();
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/utils/time_format.dart';
import '../../data/models/notification_item.dart';
class NotificationListItem extends StatelessWidget {
@@ -18,90 +19,76 @@ class NotificationListItem extends StatelessWidget {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
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,
),
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,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!item.isRead)
Container(
margin: const EdgeInsets.only(
top: AppSpacing.sm,
right: AppSpacing.sm,
),
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,
),
),
],
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(
formatRelativeTime(context, 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}';
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../features/notifications/data/models/notification_item.dart';
import '../../theme/design_tokens.dart';
import '../../utils/time_format.dart';
class NotificationDetailBottomSheet extends StatefulWidget {
const NotificationDetailBottomSheet({
@@ -76,7 +77,7 @@ class _NotificationDetailBottomSheetState
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Text(
_formatTime(widget.item.createdAt),
formatRelativeTime(context, widget.item.createdAt),
style: textTheme.labelSmall?.copyWith(color: colors.outline),
),
),
@@ -97,16 +98,6 @@ class _NotificationDetailBottomSheetState
),
);
}
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}';
}
}
Future<void> showNotificationDetailBottomSheet({