announcement_list_page.dart 14 KB

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