announcement_list_page.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  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 '../../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) => isAdmin
  26. ? [l10n.get('all'), l10n.get('noticeAnnouncement'), l10n.get('hrPolicy'), l10n.get('holidayActivity'), l10n.get('myDrafts')]
  27. : [l10n.get('all'), l10n.get('noticeAnnouncement'), l10n.get('hrPolicy'), l10n.get('holidayActivity')];
  28. @override
  29. void initState() {
  30. super.initState();
  31. final isAdmin = ref.read(isAdminProvider);
  32. _tabCtrl = TabController(
  33. length: isAdmin ? 5 : 4,
  34. vsync: this,
  35. );
  36. _tabCtrl.addListener(_onTabChanged);
  37. }
  38. void _onTabChanged() {
  39. if (!_tabCtrl.indexIsChanging) {
  40. ref.read(announcementTabProvider.notifier).state = _tabCtrl.index;
  41. }
  42. }
  43. @override
  44. void dispose() {
  45. _tabCtrl.dispose();
  46. super.dispose();
  47. }
  48. @override
  49. Widget build(BuildContext context) {
  50. final isAdmin = ref.watch(isAdminProvider);
  51. final l10n = AppLocalizations.of(context);
  52. final tabs = _getTabLabels(isAdmin, l10n);
  53. final tabIndex = ref.watch(announcementTabProvider);
  54. final r = ResponsiveHelper.of(context);
  55. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  56. // Handle TabController recreation when tab count changes (isAdmin toggle)
  57. if (_tabCtrl.length != tabs.length) {
  58. WidgetsBinding.instance.addPostFrameCallback((_) {
  59. if (!mounted) return;
  60. final newIdx = tabIndex >= tabs.length ? 0 : tabIndex;
  61. _tabCtrl.dispose();
  62. _tabCtrl = TabController(
  63. length: tabs.length,
  64. vsync: this,
  65. initialIndex: newIdx,
  66. );
  67. _tabCtrl.addListener(_onTabChanged);
  68. setState(() {});
  69. });
  70. // Render a simplified view during this frame to avoid length mismatch
  71. return Center(
  72. child: ConstrainedBox(
  73. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  74. child: Column(
  75. children: [
  76. Container(
  77. color: colors.bgCard,
  78. padding: const EdgeInsets.symmetric(horizontal: 8),
  79. child: TDTabBar(
  80. tabs: tabs.map((l) => TDTab(text: l)).toList(),
  81. isScrollable: true,
  82. labelColor: colors.primary,
  83. unselectedLabelColor: colors.textSecondary,
  84. outlineType: TDTabBarOutlineType.filled,
  85. showIndicator: true,
  86. indicatorColor: colors.primary,
  87. indicatorHeight: 3,
  88. dividerHeight: 0,
  89. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  90. ),
  91. ),
  92. const Expanded(child: SizedBox.shrink()),
  93. ],
  94. ),
  95. ),
  96. );
  97. }
  98. // Sync TabController with external changes
  99. if (_tabCtrl.index != tabIndex && !_tabCtrl.indexIsChanging) {
  100. WidgetsBinding.instance.addPostFrameCallback((_) {
  101. if (mounted) _tabCtrl.animateTo(tabIndex);
  102. });
  103. }
  104. ref
  105. .read(navBarConfigProvider.notifier)
  106. .update(
  107. NavBarConfig(
  108. title: l10n.get('announcementList'),
  109. showBack: true,
  110. onBack: () => context.pop(),
  111. ),
  112. );
  113. return Center(
  114. child: ConstrainedBox(
  115. constraints: BoxConstraints(maxWidth: r.listMaxWidth),
  116. child: Column(
  117. children: [
  118. Container(
  119. color: colors.bgCard,
  120. padding: const EdgeInsets.symmetric(horizontal: 8),
  121. child: TDTabBar(
  122. tabs: tabs.map((l) => TDTab(text: l)).toList(),
  123. controller: _tabCtrl,
  124. isScrollable: true,
  125. labelColor: colors.primary,
  126. unselectedLabelColor: colors.textSecondary,
  127. outlineType: TDTabBarOutlineType.filled,
  128. showIndicator: true,
  129. indicatorColor: colors.primary,
  130. indicatorHeight: 3,
  131. dividerHeight: 0,
  132. labelPadding: const EdgeInsets.symmetric(horizontal: 12),
  133. onTap: (index) {
  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. final l10n = AppLocalizations.of(context);
  180. if (itemsAsync.isReloading) {
  181. final oldItems = itemsAsync.valueOrNull ?? [];
  182. if (oldItems.isEmpty) {
  183. return ListView(
  184. children: [
  185. const SizedBox(height: 120),
  186. EmptyState(message: l10n.get('noAnnouncements')),
  187. ],
  188. );
  189. }
  190. return ListView.builder(
  191. padding: const EdgeInsets.symmetric(vertical: 8),
  192. itemCount: oldItems.length,
  193. itemBuilder: (_, i) => _buildAnnouncementCard(context, oldItems[i]),
  194. );
  195. }
  196. if (itemsAsync.hasError) {
  197. return ListView(
  198. children: [
  199. const SizedBox(height: 120),
  200. EmptyState(message: l10n.get('loadFailed')),
  201. ],
  202. );
  203. }
  204. final items = itemsAsync.requireValue;
  205. if (items.isEmpty) {
  206. return ListView(
  207. children: [
  208. const SizedBox(height: 120),
  209. EmptyState(message: l10n.get('noAnnouncements')),
  210. ],
  211. );
  212. }
  213. return ListView.builder(
  214. padding: const EdgeInsets.symmetric(vertical: 8),
  215. itemCount: items.length,
  216. itemBuilder: (_, i) => _buildAnnouncementCard(context, items[i]),
  217. );
  218. }
  219. Widget _buildAnnouncementCard(BuildContext context, AnnouncementModel item) {
  220. final l10n = AppLocalizations.of(context);
  221. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  222. final expired = item.isExpired;
  223. return Padding(
  224. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
  225. child: GestureDetector(
  226. onTap: () => context.push('/announcement/detail/${item.id}'),
  227. child: AnimatedContainer(
  228. duration: const Duration(milliseconds: 200),
  229. padding: const EdgeInsets.all(12),
  230. decoration: BoxDecoration(
  231. borderRadius: BorderRadius.circular(8),
  232. color: expired
  233. ? const Color(0xFFF9F9F9)
  234. : colors.bgCard,
  235. ),
  236. child: Column(
  237. crossAxisAlignment: CrossAxisAlignment.start,
  238. children: [
  239. // 标题行
  240. Row(
  241. children: [
  242. // 置顶标记
  243. if (item.isTop)
  244. Container(
  245. margin: const EdgeInsets.only(right: 6),
  246. padding: const EdgeInsets.symmetric(
  247. horizontal: 4, vertical: 1),
  248. decoration: BoxDecoration(
  249. color: colors.danger,
  250. borderRadius: BorderRadius.circular(2),
  251. ),
  252. child: Text(l10n.get('pinTopTag'),
  253. style: const TextStyle(
  254. fontSize: 10, color: Colors.white)),
  255. ),
  256. // 标题
  257. Expanded(
  258. child: Text(
  259. item.title,
  260. maxLines: 1,
  261. overflow: TextOverflow.ellipsis,
  262. style: TextStyle(
  263. fontSize: 15,
  264. fontWeight: FontWeight.w600,
  265. color: expired
  266. ? colors.textPlaceholder
  267. : colors.textPrimary,
  268. decoration:
  269. expired ? TextDecoration.lineThrough : null,
  270. ),
  271. ),
  272. ),
  273. // 已过期标记
  274. if (expired)
  275. Container(
  276. margin: const EdgeInsets.only(left: 6),
  277. padding: const EdgeInsets.symmetric(
  278. horizontal: 6, vertical: 2),
  279. decoration: BoxDecoration(
  280. color: colors.bgPage,
  281. borderRadius: BorderRadius.circular(3),
  282. ),
  283. child: Text(l10n.get('expired'),
  284. style: TextStyle(
  285. fontSize: 10, color: colors.textPlaceholder)),
  286. ),
  287. // 未读红点
  288. if (!item.isRead && !expired)
  289. Container(
  290. margin: const EdgeInsets.only(left: 6),
  291. width: 8,
  292. height: 8,
  293. decoration: BoxDecoration(
  294. color: colors.danger,
  295. shape: BoxShape.circle,
  296. ),
  297. ),
  298. ],
  299. ),
  300. const SizedBox(height: 8),
  301. // 元信息行
  302. Row(
  303. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  304. children: [
  305. Row(
  306. children: [
  307. _buildTypeTag(context, item.typeLabel, l10n),
  308. const SizedBox(width: 8),
  309. Text(
  310. item.publisherName,
  311. style: TextStyle(
  312. fontSize: 12, color: colors.textSecondary),
  313. ),
  314. ],
  315. ),
  316. Text(
  317. du.DateUtils.formatDateTime(item.publishTime),
  318. style: TextStyle(
  319. fontSize: 12, color: colors.textPlaceholder),
  320. ),
  321. ],
  322. ),
  323. ],
  324. ),
  325. ),
  326. ),
  327. );
  328. }
  329. Widget _buildTypeTag(BuildContext context, String type, AppLocalizations l10n) {
  330. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  331. Color bgColor;
  332. Color textColor;
  333. switch (type) {
  334. case '人事与制度':
  335. bgColor = colors.successBg;
  336. textColor = colors.success;
  337. break;
  338. case '放假与活动':
  339. bgColor = colors.warningBg;
  340. textColor = colors.warning;
  341. break;
  342. default: // 通知公告
  343. bgColor = colors.primaryLight;
  344. textColor = colors.primary;
  345. }
  346. final displayText = switch (type) {
  347. '人事与制度' => l10n.get('hrPolicy'),
  348. '放假与活动' => l10n.get('holidayActivity'),
  349. _ => l10n.get('noticeAnnouncement'),
  350. };
  351. return Container(
  352. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  353. decoration: BoxDecoration(
  354. color: bgColor,
  355. borderRadius: BorderRadius.circular(3),
  356. ),
  357. child:
  358. Text(displayText, style: TextStyle(fontSize: 11, color: textColor)),
  359. );
  360. }
  361. }