feat(notification): 通知标题和正文支持多语言
- 通知静态配置支持 title/body i18n - 前端通知列表和详情页展示本地化内容 - 新增数据库迁移脚本 - 更新通知协议文档
This commit is contained in:
@@ -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,
|
||||
|
||||
+71
-25
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user