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
@@ -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();
}