announcement_list_page.dart 11 KB

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