overtime_list_page.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  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/i18n/app_localizations.dart';
  7. import '../../core/theme/app_colors_extension.dart';
  8. import '../../core/utils/date_utils.dart' as du;
  9. import '../../shared/widgets/app_skeletons.dart';
  10. import '../../shared/widgets/date_range_picker.dart';
  11. import '../../shared/widgets/empty_state.dart';
  12. import '../../shared/widgets/list_card.dart';
  13. import '../../shared/widgets/list_footer.dart';
  14. import 'overtime_list_controller.dart';
  15. import 'overtime_model.dart';
  16. class OvertimeListPage extends ConsumerStatefulWidget {
  17. const OvertimeListPage({super.key});
  18. @override
  19. ConsumerState<OvertimeListPage> createState() => _OvertimeListPageState();
  20. }
  21. class _OvertimeListPageState extends ConsumerState<OvertimeListPage> {
  22. String _keyword = '';
  23. final _startDateCtrl = TextEditingController();
  24. final _endDateCtrl = TextEditingController();
  25. late final EasyRefreshController _refreshCtrl;
  26. bool _firstBuild = true;
  27. bool _invalidateDone = false;
  28. bool _filterLoading = false;
  29. final _scrollCtrl = ScrollController();
  30. bool _showBackToTop = false;
  31. @override
  32. void initState() {
  33. super.initState();
  34. final now = DateTime.now();
  35. _startDateCtrl.text =
  36. '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
  37. _endDateCtrl.text =
  38. '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
  39. _refreshCtrl = EasyRefreshController();
  40. _scrollCtrl.addListener(() {
  41. final show = _scrollCtrl.hasClients && _scrollCtrl.offset > 300;
  42. if (show != _showBackToTop && mounted) {
  43. setState(() => _showBackToTop = show);
  44. }
  45. });
  46. WidgetsBinding.instance.addPostFrameCallback((_) {
  47. ref.read(overtimeDateStartProvider.notifier).state = DateTime(
  48. now.year,
  49. now.month,
  50. 1,
  51. );
  52. ref.read(overtimeDateEndProvider.notifier).state = DateTime(
  53. now.year,
  54. now.month,
  55. DateTime(now.year, now.month + 1, 0).day,
  56. );
  57. ref.invalidate(overtimeListProvider(''));
  58. _invalidateDone = true;
  59. setState(() {}); // 触发重建以挂载 ref.listen
  60. });
  61. }
  62. @override
  63. void dispose() {
  64. _startDateCtrl.dispose();
  65. _endDateCtrl.dispose();
  66. _refreshCtrl.dispose();
  67. _scrollCtrl.dispose();
  68. super.dispose();
  69. }
  70. void _applyFilter() {
  71. FocusManager.instance.primaryFocus?.unfocus();
  72. setState(() => _filterLoading = true);
  73. _doRefresh();
  74. }
  75. Future<void> _doRefresh() async {
  76. ref.read(overtimeKeywordProvider.notifier).state = _keyword;
  77. ref
  78. .read(overtimeDateStartProvider.notifier)
  79. .state = _startDateCtrl.text.isNotEmpty
  80. ? DateTime.tryParse(_startDateCtrl.text)
  81. : null;
  82. ref
  83. .read(overtimeDateEndProvider.notifier)
  84. .state = _endDateCtrl.text.isNotEmpty
  85. ? DateTime.tryParse(_endDateCtrl.text)
  86. : null;
  87. ref.read(overtimeRefreshProvider.notifier).state++;
  88. }
  89. @override
  90. Widget build(BuildContext context) {
  91. final l10n = AppLocalizations.of(context);
  92. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  93. final tdTheme = TDTheme.of(context);
  94. // invalidate 后才挂载监听
  95. if (_invalidateDone) {
  96. ref.listen(overtimeListProvider(''), (prev, next) {
  97. if (_firstBuild && !next.isLoading && !next.isReloading) {
  98. setState(() => _firstBuild = false);
  99. WidgetsBinding.instance.addPostFrameCallback((_) {
  100. if (mounted) setState(() {});
  101. });
  102. }
  103. if (_filterLoading && !next.isReloading && !next.isLoading) {
  104. setState(() => _filterLoading = false);
  105. }
  106. });
  107. }
  108. return Stack(
  109. children: [
  110. Column(
  111. children: [
  112. Container(
  113. decoration: BoxDecoration(
  114. color: colors.bgCard,
  115. border: Border(
  116. bottom: BorderSide(color: tdTheme.componentStrokeColor),
  117. ),
  118. ),
  119. child: Column(
  120. mainAxisSize: MainAxisSize.min,
  121. children: [
  122. Padding(
  123. padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
  124. child: DateRangePicker(
  125. startCtrl: _startDateCtrl,
  126. endCtrl: _endDateCtrl,
  127. onChanged: () {
  128. setState(() {});
  129. _applyFilter();
  130. },
  131. ),
  132. ),
  133. const SizedBox(height: 8),
  134. Padding(
  135. padding: const EdgeInsets.fromLTRB(0, 0, 12, 8),
  136. child: Row(
  137. children: [
  138. Expanded(
  139. child: TDSearchBar(
  140. placeHolder: l10n.get('searchOvertime'),
  141. style: TDSearchStyle.round,
  142. onTextChanged: (String text) {
  143. setState(() {
  144. _keyword = text;
  145. });
  146. if (text.isEmpty) _applyFilter();
  147. },
  148. onSubmitted: (_) => _applyFilter(),
  149. ),
  150. ),
  151. GestureDetector(
  152. onTap: () {
  153. final dir = ref.read(overtimeSortDirProvider);
  154. ref.read(overtimeSortDirProvider.notifier).state =
  155. dir == 'ASC' ? 'DESC' : 'ASC';
  156. _applyFilter();
  157. },
  158. child: Container(
  159. width: 40,
  160. height: 40,
  161. decoration: BoxDecoration(
  162. color: colors.primary,
  163. borderRadius: BorderRadius.circular(20),
  164. ),
  165. child: Center(
  166. child: Icon(
  167. ref.watch(overtimeSortDirProvider) == 'ASC'
  168. ? Icons.arrow_upward
  169. : Icons.arrow_downward,
  170. color: Colors.white,
  171. size: 20,
  172. ),
  173. ),
  174. ),
  175. ),
  176. ],
  177. ),
  178. ),
  179. ],
  180. ),
  181. ),
  182. Expanded(
  183. child: Container(
  184. color: colors.bgPage,
  185. child: Stack(
  186. children: [
  187. _firstBuild
  188. ? const Center(child: SkeletonLoadingList())
  189. : _OvertimeListContent(
  190. refreshCtrl: _refreshCtrl,
  191. onRefresh: _doRefresh,
  192. scrollCtrl: _scrollCtrl,
  193. ),
  194. if (_filterLoading) ...[
  195. Container(color: colors.bgPage.withValues(alpha: 0.6)),
  196. const Center(
  197. child: TDLoading(
  198. size: TDLoadingSize.medium,
  199. icon: TDLoadingIcon.activity,
  200. ),
  201. ),
  202. ],
  203. ],
  204. ),
  205. ),
  206. ),
  207. ],
  208. ),
  209. AnimatedPositioned(
  210. duration: const Duration(milliseconds: 200),
  211. bottom: _showBackToTop ? 80 : -60,
  212. left: 0,
  213. right: 0,
  214. child: Center(
  215. child: GestureDetector(
  216. onTap: () {
  217. if (_scrollCtrl.hasClients) {
  218. _scrollCtrl.jumpTo(0);
  219. }
  220. },
  221. child: AnimatedOpacity(
  222. duration: const Duration(milliseconds: 200),
  223. opacity: _showBackToTop ? 1.0 : 0.0,
  224. child: Container(
  225. width: 36,
  226. height: 36,
  227. decoration: BoxDecoration(
  228. color: colors.primary400,
  229. shape: BoxShape.circle,
  230. boxShadow: [
  231. BoxShadow(
  232. color: Colors.black.withValues(alpha: 0.15),
  233. blurRadius: 6,
  234. offset: const Offset(0, 2),
  235. ),
  236. ],
  237. ),
  238. child: const Icon(
  239. Icons.keyboard_arrow_up,
  240. color: Colors.white,
  241. size: 22,
  242. ),
  243. ),
  244. ),
  245. ),
  246. ),
  247. ),
  248. Positioned(
  249. right: 16,
  250. bottom: 16,
  251. child: FloatingActionButton(
  252. onPressed: () => context.push('/report/overtime-report'),
  253. backgroundColor: colors.primary,
  254. shape: const CircleBorder(),
  255. child: const Icon(Icons.bar_chart, color: Colors.white),
  256. ),
  257. ),
  258. ],
  259. );
  260. }
  261. }
  262. class _OvertimeListContent extends ConsumerWidget {
  263. final EasyRefreshController refreshCtrl;
  264. final Future<void> Function() onRefresh;
  265. final ScrollController scrollCtrl;
  266. const _OvertimeListContent({
  267. required this.refreshCtrl,
  268. required this.onRefresh,
  269. required this.scrollCtrl,
  270. });
  271. @override
  272. Widget build(BuildContext context, WidgetRef ref) {
  273. final itemsAsync = ref.watch(overtimeListProvider(''));
  274. if (itemsAsync.isLoading && !itemsAsync.hasValue) {
  275. return const SkeletonLoadingList();
  276. }
  277. return EasyRefresh(
  278. controller: refreshCtrl,
  279. header: TDRefreshHeader(),
  280. onRefresh: onRefresh,
  281. child: _buildContent(itemsAsync, context, ref),
  282. );
  283. }
  284. Widget _buildContent(
  285. AsyncValue<List<OvertimeModel>> itemsAsync,
  286. BuildContext context,
  287. WidgetRef ref,
  288. ) {
  289. final l10n = AppLocalizations.of(context);
  290. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  291. if (itemsAsync.isReloading) {
  292. final oldItems = itemsAsync.valueOrNull ?? [];
  293. if (oldItems.isEmpty) {
  294. return ListView(
  295. children: [
  296. const SizedBox(height: 120),
  297. EmptyState(message: l10n.get('noOvertimes')),
  298. ],
  299. );
  300. }
  301. return ListView.builder(
  302. controller: scrollCtrl,
  303. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  304. itemCount: oldItems.length,
  305. itemBuilder: (_, i) {
  306. final m = oldItems[i];
  307. final card = ListCard(
  308. cardNo: m.billNo,
  309. amount: '',
  310. applicant: '${m.salesman} · ${m.dept}',
  311. description:
  312. '${m.billType}加班\n${m.remark.isNotEmpty ? '备注: ${m.remark}' : ''}',
  313. date: du.DateUtils.formatDate(m.createTime),
  314. statusTag: _buildAuditTag(m, l10n, colors),
  315. onTap: () {
  316. context.push('/overtime/detail/${m.billNo}');
  317. },
  318. );
  319. return Padding(
  320. padding: const EdgeInsets.only(bottom: 16),
  321. child: card,
  322. );
  323. },
  324. );
  325. }
  326. if (itemsAsync.hasError) {
  327. return ListView(
  328. children: [
  329. const SizedBox(height: 120),
  330. EmptyState(message: l10n.get('loadFailed')),
  331. ],
  332. );
  333. }
  334. final items = itemsAsync.requireValue;
  335. if (items.isEmpty) {
  336. return ListView(
  337. children: [
  338. const SizedBox(height: 120),
  339. EmptyState(message: l10n.get('noOvertimes')),
  340. ],
  341. );
  342. }
  343. return ListView.builder(
  344. controller: scrollCtrl,
  345. padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
  346. itemCount: items.length + 1,
  347. itemBuilder: (_, i) {
  348. if (i == items.length) return ListFooter(itemCount: items.length);
  349. final m = items[i];
  350. final card = ListCard(
  351. cardNo: m.billNo,
  352. amount: '',
  353. applicant: '${m.salesman} · ${m.dept}',
  354. description:
  355. '${m.billType}加班\n${m.remark.isNotEmpty ? '备注: ${m.remark}' : ''}',
  356. date: du.DateUtils.formatDate(m.createTime),
  357. statusTag: _buildAuditTag(m, l10n, colors),
  358. onTap: () {
  359. context.push('/overtime/detail/${m.billNo}');
  360. },
  361. );
  362. return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
  363. },
  364. );
  365. }
  366. Widget _buildAuditTag(
  367. OvertimeModel item,
  368. AppLocalizations l10n,
  369. AppColorsExtension colors,
  370. ) {
  371. if (item.isAuditApproved) {
  372. return Container(
  373. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  374. decoration: BoxDecoration(
  375. color: colors.success.withValues(alpha: 0.1),
  376. borderRadius: BorderRadius.circular(4),
  377. ),
  378. child: Text(
  379. l10n.get('statusApproved'),
  380. style: TextStyle(
  381. fontSize: 11,
  382. fontWeight: FontWeight.w500,
  383. color: colors.success,
  384. ),
  385. ),
  386. );
  387. }
  388. return Container(
  389. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  390. decoration: BoxDecoration(
  391. color: colors.statusGray.withValues(alpha: 0.1),
  392. borderRadius: BorderRadius.circular(4),
  393. ),
  394. child: Text(
  395. l10n.get('statusPending'),
  396. style: TextStyle(
  397. fontSize: 11,
  398. fontWeight: FontWeight.w500,
  399. color: colors.statusGray,
  400. ),
  401. ),
  402. );
  403. }
  404. }