271 lines
9.2 KiB
Dart
271 lines
9.2 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../../../../app/theme/app_colors.dart';
|
||
import '../../../../app/theme/app_typography.dart';
|
||
import '../../../../app/i18n/app_localizations.dart';
|
||
import '../../../../shared/widgets/empty_state.dart';
|
||
import '../../../../core/services/notification_service.dart';
|
||
import '../../../../core/providers/notification_badge_manager.dart';
|
||
|
||
/// A8. 消息模块 — 通知 + 公告
|
||
///
|
||
/// Tab 0: 全部(用户通知,不过滤类型)
|
||
/// Tab 1: 系统(type=SYSTEM)
|
||
/// Tab 2: 公告(独立接口 /api/v1/announcements)
|
||
/// Tab 3: 活动(type=ACTIVITY)
|
||
///
|
||
/// 注意:通知和公告是两套独立的后端接口,标记已读/全部已读
|
||
/// 需要根据当前 Tab 调用对应的接口。
|
||
class MessagePage extends StatefulWidget {
|
||
const MessagePage({super.key});
|
||
|
||
@override
|
||
State<MessagePage> createState() => _MessagePageState();
|
||
}
|
||
|
||
class _MessagePageState extends State<MessagePage>
|
||
with SingleTickerProviderStateMixin {
|
||
late TabController _tabController;
|
||
final NotificationService _notificationService = NotificationService();
|
||
|
||
List<NotificationItem> _items = [];
|
||
bool _isLoading = false;
|
||
String? _error;
|
||
|
||
// 当前是公告 Tab(index=2),需要使用公告 API
|
||
bool get _isAnnouncementsTab => _tabController.index == 2;
|
||
|
||
// 按 Tab index 对应的通知类型过滤(仅对通知 Tab 有效)
|
||
static const _tabTypeFilter = <int, NotificationType?>{
|
||
0: null,
|
||
1: NotificationType.system,
|
||
2: null, // 公告 Tab,类型无意义
|
||
3: NotificationType.activity,
|
||
};
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tabController = TabController(length: 4, vsync: this);
|
||
_tabController.addListener(() {
|
||
if (!_tabController.indexIsChanging) {
|
||
_load();
|
||
}
|
||
});
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tabController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ── 数据加载 ──────────────────────────────────────────────────────────────
|
||
|
||
Future<void> _load() async {
|
||
setState(() {
|
||
_isLoading = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final List<NotificationItem> result;
|
||
if (_isAnnouncementsTab) {
|
||
// Tab 2:公告,使用独立的公告接口
|
||
final resp = await _notificationService.getAnnouncements(page: 1, limit: 50);
|
||
result = resp.notifications;
|
||
} else {
|
||
// Tab 0/1/3:用户通知
|
||
final resp = await _notificationService.getNotifications(
|
||
type: _tabTypeFilter[_tabController.index],
|
||
page: 1,
|
||
limit: 50,
|
||
);
|
||
result = resp.notifications;
|
||
}
|
||
if (mounted) setState(() { _items = result; _isLoading = false; });
|
||
} catch (e) {
|
||
if (mounted) setState(() { _error = e.toString(); _isLoading = false; });
|
||
}
|
||
}
|
||
|
||
// ── 操作 ──────────────────────────────────────────────────────────────────
|
||
|
||
Future<void> _markAllAsRead() async {
|
||
final bool success;
|
||
if (_isAnnouncementsTab) {
|
||
success = await _notificationService.markAllAnnouncementsAsRead();
|
||
} else {
|
||
// 通知全部已读(后端有 PUT /api/v1/notifications/read-all)
|
||
success = await _notificationService.markAllNotificationsAsRead();
|
||
}
|
||
if (success) {
|
||
NotificationBadgeManager().clearCount();
|
||
_load();
|
||
}
|
||
}
|
||
|
||
Future<void> _markAsRead(NotificationItem item) async {
|
||
if (item.isRead) return;
|
||
final bool success;
|
||
if (_isAnnouncementsTab) {
|
||
success = await _notificationService.markAnnouncementAsRead(item.id);
|
||
} else {
|
||
success = await _notificationService.markAsRead(item.id);
|
||
}
|
||
if (success) {
|
||
NotificationBadgeManager().decrementCount();
|
||
_load();
|
||
}
|
||
}
|
||
|
||
void _openDetail(NotificationItem item) {
|
||
_markAsRead(item);
|
||
// 传递 NotificationItem 让详情页直接渲染,无需二次请求
|
||
Navigator.pushNamed(context, '/message/detail', arguments: item);
|
||
}
|
||
|
||
// ── Build ─────────────────────────────────────────────────────────────────
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
automaticallyImplyLeading: false,
|
||
title: Text(context.t('message.title')),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: _markAllAsRead,
|
||
child: Text(
|
||
context.t('notification.markAllRead'),
|
||
style: AppTypography.labelSmall.copyWith(color: AppColors.primary),
|
||
),
|
||
),
|
||
],
|
||
bottom: TabBar(
|
||
controller: _tabController,
|
||
isScrollable: true,
|
||
tabAlignment: TabAlignment.start,
|
||
tabs: [
|
||
Tab(text: context.t('common.all')),
|
||
Tab(text: context.t('notification.system')),
|
||
Tab(text: context.t('notification.announcement')),
|
||
Tab(text: context.t('notification.activity')),
|
||
],
|
||
),
|
||
),
|
||
body: _isLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _error != null
|
||
? _buildErrorView()
|
||
: _items.isEmpty
|
||
? EmptyState.noMessages(context)
|
||
: RefreshIndicator(
|
||
onRefresh: _load,
|
||
child: ListView.separated(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
itemCount: _items.length,
|
||
separatorBuilder: (_, __) => const Divider(indent: 76),
|
||
itemBuilder: (_, i) => _buildItem(_items[i]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildErrorView() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Text(context.t('notification.loadFailed'), style: AppTypography.bodyMedium),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton(
|
||
onPressed: _load,
|
||
child: Text(context.t('notification.retry')),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildItem(NotificationItem item) {
|
||
return ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||
leading: Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: _iconColor(item.type).withValues(alpha: 0.1),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(_iconData(item.type), size: 22, color: _iconColor(item.type)),
|
||
),
|
||
title: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
item.title,
|
||
style: AppTypography.labelMedium.copyWith(
|
||
fontWeight: item.isRead ? FontWeight.w400 : FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
if (item.publishedAt != null)
|
||
Text(_formatTime(context, item.publishedAt!), style: AppTypography.caption),
|
||
],
|
||
),
|
||
subtitle: Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
item.content,
|
||
style: AppTypography.bodySmall,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
trailing: !item.isRead
|
||
? Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.primary,
|
||
shape: BoxShape.circle,
|
||
),
|
||
)
|
||
: null,
|
||
onTap: () => _openDetail(item),
|
||
);
|
||
}
|
||
|
||
// ── 工具方法 ──────────────────────────────────────────────────────────────
|
||
|
||
String _formatTime(BuildContext context, DateTime time) {
|
||
final now = DateTime.now();
|
||
final diff = now.difference(time);
|
||
if (diff.inMinutes < 1) return context.t('time.justNow');
|
||
if (diff.inMinutes < 60) return context.t('time.minutesAgo').replaceAll('{n}', '${diff.inMinutes}');
|
||
if (diff.inHours < 24) return context.t('time.hoursAgo').replaceAll('{n}', '${diff.inHours}');
|
||
if (diff.inDays < 7) return context.t('time.daysAgo').replaceAll('{n}', '${diff.inDays}');
|
||
return '${time.month}/${time.day}';
|
||
}
|
||
|
||
IconData _iconData(NotificationType type) {
|
||
switch (type) {
|
||
case NotificationType.system: return Icons.settings_rounded;
|
||
case NotificationType.activity: return Icons.swap_horiz_rounded;
|
||
case NotificationType.reward: return Icons.card_giftcard_rounded;
|
||
case NotificationType.upgrade: return Icons.system_update_rounded;
|
||
case NotificationType.announcement: return Icons.campaign_rounded;
|
||
}
|
||
}
|
||
|
||
Color _iconColor(NotificationType type) {
|
||
switch (type) {
|
||
case NotificationType.system: return AppColors.primary;
|
||
case NotificationType.activity: return AppColors.success;
|
||
case NotificationType.reward: return AppColors.warning;
|
||
case NotificationType.upgrade: return AppColors.info;
|
||
case NotificationType.announcement: return AppColors.info;
|
||
}
|
||
}
|
||
}
|