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