feat: 添加消息中心及顶栏未读角标功能
新增消息中心页面,实现未读消息轮询与展示 为各 Tab 页面顶栏添加通知图标及未读数角标 新增通知 API 接口与未读数状态管理 实现消息标记已读、全部已读及下拉刷新功能
This commit is contained in:
1 parent
66013ee069
commit
7ad604ff8b
9 files changed
+575
-5
No files matched your search
@@ -0,0 +1,330 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_chip.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../data/notice_api.dart';
|
||||
|
||||
/// 消息中心(二级页):列表 + 未读高亮 + 全部已读 + 下拉刷新 + 分页加载。
|
||||
class NoticeCenterPage extends ConsumerStatefulWidget {
|
||||
const NoticeCenterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NoticeCenterPage> createState() => _NoticeCenterPageState();
|
||||
}
|
||||
|
||||
class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
static const int _pageSize = 15;
|
||||
|
||||
static const Map<String, String> _typeLabels = {
|
||||
'cert_expire': '证照到期',
|
||||
'contract_expire': '合同到期',
|
||||
'worker_audit_result': '电工审核',
|
||||
'dispatch_assign': '派单通知',
|
||||
'visit_plan': '拜访计划',
|
||||
'order_timeout': '工单超时',
|
||||
'manual': '系统通知',
|
||||
};
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
List<Notice> _list = const [];
|
||||
int _total = 0;
|
||||
int _page = 1;
|
||||
bool _hasMore = true;
|
||||
bool _loading = false;
|
||||
bool _markingAll = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_load(reset: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final position = _scrollController.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
|
||||
}
|
||||
|
||||
Future<void> _load({bool reset = false}) async {
|
||||
if (_loading) return;
|
||||
setState(() => _loading = true);
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_hasMore = true;
|
||||
}
|
||||
try {
|
||||
final res = await ref
|
||||
.read(noticeApiProvider)
|
||||
.list(page: _page, pageSize: _pageSize);
|
||||
if (res.isOk && res.data != null && mounted) {
|
||||
final data = res.data!;
|
||||
setState(() {
|
||||
_list = reset ? data.list : [..._list, ...data.list];
|
||||
_total = data.total;
|
||||
_hasMore = _list.length < data.total;
|
||||
if (_hasMore) _page += 1;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) showAppToast('加载消息失败');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _loadMore() {
|
||||
if (_hasMore && !_loading) _load();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _load(reset: true);
|
||||
await ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||
}
|
||||
|
||||
Future<void> _markRead(Notice notice) async {
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).markRead(notice.id);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
showAppToast(res.message.isEmpty ? '标记已读失败' : res.message);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_list = [
|
||||
for (final e in _list) e.id == notice.id ? e.copyWith(isRead: 1) : e,
|
||||
];
|
||||
});
|
||||
unawaited(ref.read(noticeUnreadCountProvider.notifier).refresh());
|
||||
} catch (_) {
|
||||
if (mounted) showAppToast('标记已读失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
if (_markingAll) return;
|
||||
final ok = await showAppConfirm(
|
||||
title: '全部已读',
|
||||
content: '确定将全部消息标记为已读吗?',
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
_markingAll = true;
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).markAllRead();
|
||||
if (!mounted) return;
|
||||
if (res.isOk) {
|
||||
setState(() {
|
||||
_list = [for (final e in _list) e.copyWith(isRead: 1)];
|
||||
});
|
||||
unawaited(ref.read(noticeUnreadCountProvider.notifier).refresh());
|
||||
showAppToast('已全部标记为已读', success: true);
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) showAppToast('操作失败');
|
||||
} finally {
|
||||
_markingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) =>
|
||||
_typeLabels[type] ?? (type.isEmpty ? '通知' : type);
|
||||
|
||||
ChipTone _typeTone(String type) => switch (type) {
|
||||
'cert_expire' || 'contract_expire' => ChipTone.warn,
|
||||
'order_timeout' => ChipTone.error,
|
||||
'manual' => ChipTone.primary,
|
||||
_ => ChipTone.muted,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.nav(
|
||||
title: '消息中心',
|
||||
onBack: () => context.pop(),
|
||||
trailing: unread > 0
|
||||
? GestureDetector(
|
||||
onTap: _markAllRead,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Text(
|
||||
'全部已读',
|
||||
style: AppTypography.labelBold.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
color: AppColors.primary,
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 32),
|
||||
children: _buildList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildList() {
|
||||
if (_loading && _list.isEmpty) {
|
||||
return const [XPlaceholder(text: '加载中...')];
|
||||
}
|
||||
if (_list.isEmpty) {
|
||||
return const [XPlaceholder(text: '暂无消息')];
|
||||
}
|
||||
return [
|
||||
for (final notice in _list) _buildCard(notice),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 4),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_hasMore ? '加载中...' : '共 $_total 条',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildCard(Notice notice) {
|
||||
return XCard(
|
||||
clip: true,
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
onTap: notice.unread ? () => _markRead(notice) : null,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 4,
|
||||
color: notice.unread
|
||||
? AppColors.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (notice.unread) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: _UnreadDot(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
notice.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
XChip(
|
||||
_typeLabel(notice.noticeType),
|
||||
tone: _typeTone(notice.noticeType),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (notice.content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
notice.content,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const XIcon(
|
||||
'clock',
|
||||
size: 14,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formatDateTime(notice.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (notice.unread)
|
||||
Text(
|
||||
'未读',
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 未读消息左侧的实心圆点。
|
||||
class _UnreadDot extends StatelessWidget {
|
||||
const _UnreadDot();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.statusError,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user