announcement_list_page.dart 14 KB

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