announcement_list_page.dart 12 KB

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