gcx/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart

271 lines
9.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 当前是公告 Tabindex=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;
}
}
}