announcement_list_page.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import 'package:easy_refresh/easy_refresh.dart';
  6. import '../../core/theme/app_colors.dart';
  7. import '../shell/nav_bar_config.dart';
  8. import '../../core/utils/date_utils.dart' as du;
  9. import '../../core/utils/responsive.dart';
  10. import '../../shared/widgets/empty_state.dart';
  11. import '../../shared/widgets/skeleton_list_card.dart';
  12. import '../../core/i18n/app_localizations.dart';
  13. import 'announcement_list_controller.dart';
  14. import 'announcement_model.dart';
  15. class AnnouncementListPage extends ConsumerStatefulWidget {
  16. const AnnouncementListPage({super.key});
  17. @override
  18. ConsumerState<AnnouncementListPage> createState() =>
  19. _AnnouncementListPageState();
  20. }
  21. class _AnnouncementListPageState extends ConsumerState<AnnouncementListPage>
  22. with TickerProviderStateMixin {
  23. late TabController _tabCtrl;
  24. static List<String> _getTabLabels(bool isAdmin) => isAdmin
  25. ? ['全部', '通知公告', '人事与制度', '放假与活动', '我的草稿']
  26. : ['全部', '通知公告', '人事与制度', '放假与活动'];
  27. @override
  28. void initState() {
  29. super.initState();
  30. final isAdmin = ref.read(isAdminProvider);
  31. _tabCtrl = TabController(
  32. length: _getTabLabels(isAdmin).length,
  33. vsync: this,
  34. );
  35. _tabCtrl.addListener(_onTabChanged);
  36. }
  37. void _onTabChanged() {
  38. if (!_tabCtrl.indexIsChanging) {
  39. ref.read(announcementTabProvider.notifier).state = _tabCtrl.index;
  40. }
  41. }
  42. @override
  43. void dispose() {
  44. _tabCtrl.dispose();
  45. super.dispose();
  46. }
  47. @override
  48. Widget build(BuildContext context) {
  49. final isAdmin = ref.watch(isAdminProvider);
  50. final tabs = _getTabLabels(isAdmin);
  51. final tabIndex = ref.watch(announcementTabProvider);
  52. final r = ResponsiveHelper.of(context);
  53. final l10n = AppLocalizations.of(context);
  54. // Handle TabController recreation when tab count changes (isAdmin toggle)
  55. if (_tabCtrl.length != tabs.length) {
  56. WidgetsBinding.instance.addPostFrameCallback((_) {
  57. if (!mounted) return;
  58. final newIdx = tabIndex >= tabs.length ? 0 : tabIndex;
  59. _tabCtrl.dispose();
  60. _tabCtrl = TabController(
  61. length: tabs.length,
  62. vsync: this,
  63. initialIndex: newIdx,
  64. );
  65. _tabCtrl.addListener(_onTabChanged);
  66. setState(() {});
  67. });
  68. // Render a simplified view during this frame to avoid length mismatch
  69. return Center(
  70. child: ConstrainedBox(
  71. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  72. child: Column(
  73. children: [
  74. Container(
  75. color: AppColors.bgCard,
  76. padding: const EdgeInsets.symmetric(horizontal: 8),
  77. child: TDTabBar(
  78. tabs: tabs.map((l) => TDTab(text: l)).toList(),
  79. isScrollable: true,
  80. labelColor: AppColors.primary,
  81. unselectedLabelColor: AppColors.textSecondary,
  82. outlineType: TDTabBarOutlineType.filled,
  83. showIndicator: true,
  84. indicatorColor: AppColors.primary,
  85. indicatorHeight: 3,
  86. dividerHeight: 0,
  87. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  88. ),
  89. ),
  90. const Expanded(child: SizedBox.shrink()),
  91. ],
  92. ),
  93. ),
  94. );
  95. }
  96. // Sync TabController with external changes
  97. if (_tabCtrl.index != tabIndex && !_tabCtrl.indexIsChanging) {
  98. WidgetsBinding.instance.addPostFrameCallback((_) {
  99. if (mounted) _tabCtrl.animateTo(tabIndex);
  100. });
  101. }
  102. ref
  103. .read(navBarConfigProvider.notifier)
  104. .update(
  105. NavBarConfig(
  106. title: l10n.get('announcementList'),
  107. showBack: true,
  108. onBack: () => context.pop(),
  109. ),
  110. );
  111. return Center(
  112. child: ConstrainedBox(
  113. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  114. child: Column(
  115. children: [
  116. Container(
  117. color: AppColors.bgCard,
  118. padding: const EdgeInsets.symmetric(horizontal: 8),
  119. child: TDTabBar(
  120. tabs: tabs.map((l) => TDTab(text: l)).toList(),
  121. controller: _tabCtrl,
  122. isScrollable: true,
  123. labelColor: AppColors.primary,
  124. unselectedLabelColor: AppColors.textSecondary,
  125. outlineType: TDTabBarOutlineType.filled,
  126. showIndicator: true,
  127. indicatorColor: AppColors.primary,
  128. indicatorHeight: 3,
  129. dividerHeight: 0,
  130. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  131. onTap: (index) {
  132. ref.invalidate(filteredAnnouncementsProvider);
  133. ref.read(announcementTabProvider.notifier).state = index;
  134. },
  135. ),
  136. ),
  137. Expanded(
  138. child: TabBarView(
  139. controller: _tabCtrl,
  140. children: List.generate(tabs.length, (tabIdx) {
  141. return _AnnouncementTabContent(tabIndex: tabIdx);
  142. }),
  143. ),
  144. ),
  145. ],
  146. ),
  147. ),
  148. );
  149. }
  150. }
  151. class _AnnouncementTabContent extends ConsumerWidget {
  152. final int tabIndex;
  153. const _AnnouncementTabContent({required this.tabIndex});
  154. @override
  155. Widget build(BuildContext context, WidgetRef ref) {
  156. final itemsAsync = ref.watch(filteredAnnouncementsProvider(tabIndex));
  157. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  158. return SkeletonLoadingList(
  159. cardBuilder: () => const SkeletonAnnouncementCard(),
  160. );
  161. }
  162. return EasyRefresh(
  163. header: TDRefreshHeader(),
  164. onRefresh: () async {
  165. ref.read(announcementRefreshProvider.notifier).state++;
  166. },
  167. child: _buildContent(itemsAsync, context, ref),
  168. );
  169. }
  170. Widget _buildContent(
  171. AsyncValue<List<AnnouncementModel>> itemsAsync,
  172. BuildContext context,
  173. WidgetRef ref,
  174. ) {
  175. if (itemsAsync.isReloading) {
  176. final oldItems = itemsAsync.valueOrNull ?? [];
  177. if (oldItems.isEmpty) {
  178. return SkeletonLoadingList(
  179. cardBuilder: () => const SkeletonAnnouncementCard(),
  180. );
  181. }
  182. return ListView.builder(
  183. padding: const EdgeInsets.symmetric(vertical: 8),
  184. itemCount: oldItems.length,
  185. itemBuilder: (_, i) => _buildAnnouncementCard(context, oldItems[i]),
  186. );
  187. }
  188. if (itemsAsync.hasError) {
  189. return ListView(
  190. children: const [
  191. SizedBox(height: 120),
  192. EmptyState(message: '加载失败'),
  193. ],
  194. );
  195. }
  196. final items = itemsAsync.requireValue;
  197. if (items.isEmpty) {
  198. return ListView(
  199. children: const [
  200. SizedBox(height: 120),
  201. EmptyState(message: '暂无行政公告'),
  202. ],
  203. );
  204. }
  205. return ListView.builder(
  206. padding: const EdgeInsets.symmetric(vertical: 8),
  207. itemCount: items.length,
  208. itemBuilder: (_, i) => _buildAnnouncementCard(context, items[i]),
  209. );
  210. }
  211. Widget _buildAnnouncementCard(BuildContext context, AnnouncementModel item) {
  212. final expired = item.isExpired;
  213. return Padding(
  214. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  215. child: GestureDetector(
  216. onTap: () => context.push('/announcement/detail/${item.id}'),
  217. child: AnimatedContainer(
  218. duration: const Duration(milliseconds: 200),
  219. padding: const EdgeInsets.all(12),
  220. decoration: BoxDecoration(
  221. borderRadius: BorderRadius.circular(8),
  222. color: expired
  223. ? const Color(0xFFF9F9F9)
  224. : AppColors.bgCard,
  225. ),
  226. child: Column(
  227. crossAxisAlignment: CrossAxisAlignment.start,
  228. children: [
  229. // 标题行
  230. Row(
  231. children: [
  232. // 置顶标记
  233. if (item.isTop)
  234. Container(
  235. margin: const EdgeInsets.only(right: 6),
  236. padding: const EdgeInsets.symmetric(
  237. horizontal: 4, vertical: 1),
  238. decoration: BoxDecoration(
  239. color: AppColors.danger,
  240. borderRadius: BorderRadius.circular(2),
  241. ),
  242. child: const Text('置顶',
  243. style: TextStyle(
  244. fontSize: 10, color: Colors.white)),
  245. ),
  246. // 标题
  247. Expanded(
  248. child: Text(
  249. item.title,
  250. maxLines: 1,
  251. overflow: TextOverflow.ellipsis,
  252. style: TextStyle(
  253. fontSize: 15,
  254. fontWeight: FontWeight.w600,
  255. color: expired
  256. ? AppColors.textPlaceholder
  257. : AppColors.textPrimary,
  258. decoration:
  259. expired ? TextDecoration.lineThrough : null,
  260. ),
  261. ),
  262. ),
  263. // 已过期标记
  264. if (expired)
  265. Container(
  266. margin: const EdgeInsets.only(left: 6),
  267. padding: const EdgeInsets.symmetric(
  268. horizontal: 6, vertical: 2),
  269. decoration: BoxDecoration(
  270. color: AppColors.bgPage,
  271. borderRadius: BorderRadius.circular(3),
  272. ),
  273. child: const Text('已过期',
  274. style: TextStyle(
  275. fontSize: 10, color: AppColors.textPlaceholder)),
  276. ),
  277. // 未读红点
  278. if (!item.isRead && !expired)
  279. Container(
  280. margin: const EdgeInsets.only(left: 6),
  281. width: 8,
  282. height: 8,
  283. decoration: const BoxDecoration(
  284. color: AppColors.danger,
  285. shape: BoxShape.circle,
  286. ),
  287. ),
  288. ],
  289. ),
  290. const SizedBox(height: 8),
  291. // 元信息行
  292. Row(
  293. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  294. children: [
  295. Row(
  296. children: [
  297. _buildTypeTag(item.typeLabel),
  298. const SizedBox(width: 8),
  299. Text(
  300. item.publisherName,
  301. style: const TextStyle(
  302. fontSize: 12, color: AppColors.textSecondary),
  303. ),
  304. ],
  305. ),
  306. Text(
  307. du.DateUtils.formatDateTime(item.publishTime),
  308. style: const TextStyle(
  309. fontSize: 12, color: AppColors.textPlaceholder),
  310. ),
  311. ],
  312. ),
  313. ],
  314. ),
  315. ),
  316. ),
  317. );
  318. }
  319. Widget _buildTypeTag(String type) {
  320. Color bgColor;
  321. Color textColor;
  322. switch (type) {
  323. case '人事与制度':
  324. bgColor = AppColors.successBg;
  325. textColor = AppColors.success;
  326. break;
  327. case '放假与活动':
  328. bgColor = AppColors.warningBg;
  329. textColor = AppColors.warning;
  330. break;
  331. default: // 通知公告
  332. bgColor = AppColors.primaryLight;
  333. textColor = AppColors.primary;
  334. }
  335. return Container(
  336. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  337. decoration: BoxDecoration(
  338. color: bgColor,
  339. borderRadius: BorderRadius.circular(3),
  340. ),
  341. child:
  342. Text(type, style: TextStyle(fontSize: 11, color: textColor)),
  343. );
  344. }
  345. }