import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:easy_refresh/easy_refresh.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../core/utils/date_utils.dart' as du; import '../../core/utils/responsive.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/skeleton_list_card.dart'; import '../../shared/widgets/list_footer.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/auth/role_provider.dart'; import 'announcement_list_controller.dart'; import 'announcement_model.dart'; class AnnouncementListPage extends ConsumerStatefulWidget { const AnnouncementListPage({super.key}); @override ConsumerState createState() => _AnnouncementListPageState(); } class _AnnouncementListPageState extends ConsumerState with TickerProviderStateMixin { late TabController _tabCtrl; static List _getTabLabels(bool isAdmin, AppLocalizations l10n) => isAdmin ? [ l10n.get('all'), l10n.get('noticeAnnouncement'), l10n.get('hrPolicy'), l10n.get('holidayActivity'), l10n.get('draft'), ] : [ l10n.get('all'), l10n.get('noticeAnnouncement'), l10n.get('hrPolicy'), l10n.get('holidayActivity'), ]; @override void initState() { super.initState(); final isAdmin = ref.read(isAdminProvider); _tabCtrl = TabController(length: isAdmin ? 5 : 4, vsync: this); _tabCtrl.addListener(_onTabChanged); } void _onTabChanged() { if (!_tabCtrl.indexIsChanging) { ref.read(announcementTabProvider.notifier).state = _tabCtrl.index; } } @override void dispose() { _tabCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final isAdmin = ref.watch(isAdminProvider); final l10n = AppLocalizations.of(context); final tabs = _getTabLabels(isAdmin, l10n); final tabIndex = ref.watch(announcementTabProvider); final r = ResponsiveHelper.of(context); final colors = Theme.of(context).extension()!; // Handle TabController recreation when tab count changes (isAdmin toggle) if (_tabCtrl.length != tabs.length) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final newIdx = tabIndex >= tabs.length ? 0 : tabIndex; _tabCtrl.dispose(); _tabCtrl = TabController( length: tabs.length, vsync: this, initialIndex: newIdx, ); _tabCtrl.addListener(_onTabChanged); setState(() {}); }); // Render a simplified view during this frame to avoid length mismatch return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: Column( children: [ Container( color: colors.bgCard, padding: const EdgeInsets.symmetric(horizontal: 8), child: TDTabBar( tabs: tabs.map((l) => TDTab(text: l)).toList(), isScrollable: true, labelColor: colors.primary, unselectedLabelColor: colors.textSecondary, outlineType: TDTabBarOutlineType.filled, showIndicator: true, indicatorColor: colors.primary, indicatorHeight: 3, dividerHeight: 0, labelPadding: const EdgeInsets.symmetric(horizontal: 12), ), ), const Expanded(child: SizedBox.shrink()), ], ), ), ); } // Sync TabController with external changes if (_tabCtrl.index != tabIndex && !_tabCtrl.indexIsChanging) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _tabCtrl.animateTo(tabIndex); }); } ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('announcementList'), showBack: true, onBack: () => context.pop(), ), ); return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: Column( children: [ Container( color: colors.bgCard, padding: EdgeInsets.zero, child: TDSearchBar( placeHolder: l10n.get('searchAnnouncement'), needCancel: true, style: TDSearchStyle.round, ), ), Container( color: colors.bgCard, padding: const EdgeInsets.symmetric(horizontal: 8), child: TDTabBar( tabs: tabs.map((l) => TDTab(text: l)).toList(), controller: _tabCtrl, isScrollable: true, labelColor: colors.primary, unselectedLabelColor: colors.textSecondary, outlineType: TDTabBarOutlineType.filled, showIndicator: true, indicatorColor: colors.primary, indicatorHeight: 3, dividerHeight: 0, labelPadding: const EdgeInsets.symmetric(horizontal: 12), onTap: (index) { ref.read(announcementTabProvider.notifier).state = index; }, ), ), Expanded( child: Container( color: colors.bgPage, child: TabBarView( controller: _tabCtrl, children: List.generate(tabs.length, (tabIdx) { return _AnnouncementTabContent(tabIndex: tabIdx); }), ), ), ), ], ), ), ); } } class _AnnouncementTabContent extends ConsumerWidget { final int tabIndex; const _AnnouncementTabContent({required this.tabIndex}); @override Widget build(BuildContext context, WidgetRef ref) { final itemsAsync = ref.watch(filteredAnnouncementsProvider(tabIndex)); if (itemsAsync.isLoading && !itemsAsync.hasValue) { return SkeletonLoadingList( cardBuilder: () => const SkeletonAnnouncementCard(), ); } return EasyRefresh( header: TDRefreshHeader(), onRefresh: () async { ref.read(announcementRefreshProvider.notifier).state++; }, child: _buildContent(itemsAsync, context, ref), ); } Widget _buildContent( AsyncValue> itemsAsync, BuildContext context, WidgetRef ref, ) { final l10n = AppLocalizations.of(context); if (itemsAsync.isReloading) { final oldItems = itemsAsync.valueOrNull ?? []; if (oldItems.isEmpty) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('noAnnouncements')), ], ); } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: oldItems.length, itemBuilder: (_, i) => _buildAnnouncementCard(context, oldItems[i]), ); } if (itemsAsync.hasError) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed')), ], ); } final items = itemsAsync.requireValue; if (items.isEmpty) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('noAnnouncements')), ], ); } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: items.length + 1, itemBuilder: (_, i) { if (i == items.length) return ListFooter(itemCount: items.length); return _buildAnnouncementCard(context, items[i]); }, ); } Widget _buildAnnouncementCard(BuildContext context, AnnouncementModel item) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final expired = item.isExpired; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: GestureDetector( onTap: () => context.push('/announcement/detail/${item.id}'), child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: expired ? colors.bgDisabled : colors.bgCard, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标题行 Row( children: [ // 置顶标记 if (item.isTop) Container( margin: const EdgeInsets.only(right: 6), padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 1, ), decoration: BoxDecoration( color: colors.danger, borderRadius: BorderRadius.circular(2), ), child: Text( l10n.get('pinTopTag'), style: const TextStyle( fontSize: 10, color: Colors.white, ), ), ), // 标题 Expanded( child: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: expired ? colors.textPlaceholder : colors.textPrimary, decoration: expired ? TextDecoration.lineThrough : null, ), ), ), // 已过期标记 if (expired) Container( margin: const EdgeInsets.only(left: 6), padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(3), ), child: Text( l10n.get('expired'), style: TextStyle( fontSize: 10, color: colors.textPlaceholder, ), ), ), // 未读红点 if (!item.isRead && !expired) Container( margin: const EdgeInsets.only(left: 6), width: 8, height: 8, decoration: BoxDecoration( color: colors.danger, shape: BoxShape.circle, ), ), ], ), const SizedBox(height: 8), // 元信息行 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ _buildTypeTag(context, item.typeLabel, l10n), const SizedBox(width: 8), Text( item.publisherName, style: TextStyle( fontSize: 12, color: colors.textSecondary, ), ), ], ), Text( du.DateUtils.formatDateTime(item.publishTime), style: TextStyle( fontSize: 12, color: colors.textPlaceholder, ), ), ], ), ], ), ), ), ); } Widget _buildTypeTag( BuildContext context, String type, AppLocalizations l10n, ) { final colors = Theme.of(context).extension()!; Color bgColor; Color textColor; switch (type) { case '人事与制度': bgColor = colors.successBg; textColor = colors.success; break; case '放假与活动': bgColor = colors.warningBg; textColor = colors.warning; break; default: // 通知公告 bgColor = colors.primaryLight; textColor = colors.primary; } final displayText = switch (type) { '人事与制度' => l10n.get('hrPolicy'), '放假与活动' => l10n.get('holidayActivity'), _ => l10n.get('noticeAnnouncement'), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(3), ), child: Text( displayText, style: TextStyle(fontSize: 11, color: textColor), ), ); } }