import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:easy_refresh/easy_refresh.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/date_range_picker.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/list_card.dart'; import '../../shared/widgets/list_footer.dart'; import 'overtime_list_controller.dart'; import 'overtime_model.dart'; class OvertimeListPage extends ConsumerStatefulWidget { const OvertimeListPage({super.key}); @override ConsumerState createState() => _OvertimeListPageState(); } class _OvertimeListPageState extends ConsumerState { String _keyword = ''; final _startDateCtrl = TextEditingController(); final _endDateCtrl = TextEditingController(); late final EasyRefreshController _refreshCtrl; bool _firstBuild = true; bool _invalidateDone = false; bool _filterLoading = false; final _scrollCtrl = ScrollController(); bool _showBackToTop = false; @override void initState() { super.initState(); final now = DateTime.now(); _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01'; _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}'; _refreshCtrl = EasyRefreshController(); _scrollCtrl.addListener(() { final show = _scrollCtrl.hasClients && _scrollCtrl.offset > 300; if (show != _showBackToTop && mounted) { setState(() => _showBackToTop = show); } }); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(overtimeDateStartProvider.notifier).state = DateTime( now.year, now.month, 1, ); ref.read(overtimeDateEndProvider.notifier).state = DateTime( now.year, now.month, DateTime(now.year, now.month + 1, 0).day, ); ref.invalidate(overtimeListProvider('')); _invalidateDone = true; setState(() {}); // 触发重建以挂载 ref.listen }); } @override void dispose() { _startDateCtrl.dispose(); _endDateCtrl.dispose(); _refreshCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } void _applyFilter() { FocusManager.instance.primaryFocus?.unfocus(); setState(() => _filterLoading = true); _doRefresh(); } Future _doRefresh() async { ref.read(overtimeKeywordProvider.notifier).state = _keyword; ref .read(overtimeDateStartProvider.notifier) .state = _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null; ref .read(overtimeDateEndProvider.notifier) .state = _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null; ref.read(overtimeRefreshProvider.notifier).state++; } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final tdTheme = TDTheme.of(context); // invalidate 后才挂载监听 if (_invalidateDone) { ref.listen(overtimeListProvider(''), (prev, next) { if (_firstBuild && !next.isLoading && !next.isReloading) { setState(() => _firstBuild = false); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); } if (_filterLoading && !next.isReloading && !next.isLoading) { setState(() => _filterLoading = false); } }); } return Stack( children: [ Column( children: [ Container( decoration: BoxDecoration( color: colors.bgCard, border: Border( bottom: BorderSide(color: tdTheme.componentStrokeColor), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: DateRangePicker( startCtrl: _startDateCtrl, endCtrl: _endDateCtrl, onChanged: () { setState(() {}); _applyFilter(); }, ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.fromLTRB(0, 0, 12, 8), child: Row( children: [ Expanded( child: TDSearchBar( placeHolder: l10n.get('searchOvertime'), style: TDSearchStyle.round, onTextChanged: (String text) { setState(() { _keyword = text; }); if (text.isEmpty) _applyFilter(); }, onSubmitted: (_) => _applyFilter(), ), ), GestureDetector( onTap: () { final dir = ref.read(overtimeSortDirProvider); ref.read(overtimeSortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC'; _applyFilter(); }, child: Container( width: 40, height: 40, decoration: BoxDecoration( color: colors.primary, borderRadius: BorderRadius.circular(20), ), child: Center( child: Icon( ref.watch(overtimeSortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20, ), ), ), ), ], ), ), ], ), ), Expanded( child: Container( color: colors.bgPage, child: Stack( children: [ _firstBuild ? const Center(child: SkeletonLoadingList()) : _OvertimeListContent( refreshCtrl: _refreshCtrl, onRefresh: _doRefresh, scrollCtrl: _scrollCtrl, ), if (_filterLoading) ...[ Container(color: colors.bgPage.withValues(alpha: 0.6)), const Center( child: TDLoading( size: TDLoadingSize.medium, icon: TDLoadingIcon.activity, ), ), ], ], ), ), ), ], ), AnimatedPositioned( duration: const Duration(milliseconds: 200), bottom: _showBackToTop ? 80 : -60, left: 0, right: 0, child: Center( child: GestureDetector( onTap: () { if (_scrollCtrl.hasClients) { _scrollCtrl.jumpTo(0); } }, child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _showBackToTop ? 1.0 : 0.0, child: Container( width: 36, height: 36, decoration: BoxDecoration( color: colors.primary400, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.15), blurRadius: 6, offset: const Offset(0, 2), ), ], ), child: const Icon( Icons.keyboard_arrow_up, color: Colors.white, size: 22, ), ), ), ), ), ), Positioned( right: 16, bottom: 16, child: FloatingActionButton( onPressed: () => context.push('/report/overtime-report'), backgroundColor: colors.primary, shape: const CircleBorder(), child: const Icon(Icons.bar_chart, color: Colors.white), ), ), ], ); } } class _OvertimeListContent extends ConsumerWidget { final EasyRefreshController refreshCtrl; final Future Function() onRefresh; final ScrollController scrollCtrl; const _OvertimeListContent({ required this.refreshCtrl, required this.onRefresh, required this.scrollCtrl, }); @override Widget build(BuildContext context, WidgetRef ref) { final itemsAsync = ref.watch(overtimeListProvider('')); if (itemsAsync.isLoading && !itemsAsync.hasValue) { return const SkeletonLoadingList(); } return EasyRefresh( controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: onRefresh, child: _buildContent(itemsAsync, context, ref), ); } Widget _buildContent( AsyncValue> itemsAsync, BuildContext context, WidgetRef ref, ) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; if (itemsAsync.isReloading) { final oldItems = itemsAsync.valueOrNull ?? []; if (oldItems.isEmpty) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('noOvertimes')), ], ); } return ListView.builder( controller: scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) { final m = oldItems[i]; final card = ListCard( cardNo: m.billNo, amount: '', applicant: '${m.salesman} · ${m.dept}', description: '${m.billType}加班\n${m.remark.isNotEmpty ? '备注: ${m.remark}' : ''}', date: du.DateUtils.formatDate(m.createTime), statusTag: _buildAuditTag(m, l10n, colors), onTap: () { context.push('/overtime/detail/${m.billNo}'); }, ); return Padding( padding: const EdgeInsets.only(bottom: 16), child: card, ); }, ); } if (itemsAsync.hasError) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed')), ], ); } final items = itemsAsync.requireValue; if (items.isEmpty) { return ListView( children: [ const SizedBox(height: 120), EmptyState(message: l10n.get('noOvertimes')), ], ); } return ListView.builder( controller: scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) { if (i == items.length) return ListFooter(itemCount: items.length); final m = items[i]; final card = ListCard( cardNo: m.billNo, amount: '', applicant: '${m.salesman} · ${m.dept}', description: '${m.billType}加班\n${m.remark.isNotEmpty ? '备注: ${m.remark}' : ''}', date: du.DateUtils.formatDate(m.createTime), statusTag: _buildAuditTag(m, l10n, colors), onTap: () { context.push('/overtime/detail/${m.billNo}'); }, ); return Padding(padding: const EdgeInsets.only(bottom: 16), child: card); }, ); } Widget _buildAuditTag( OvertimeModel item, AppLocalizations l10n, AppColorsExtension colors, ) { if (item.isAuditApproved) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: colors.success.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( l10n.get('statusApproved'), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: colors.success, ), ), ); } return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: colors.statusGray.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( l10n.get('statusPending'), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: colors.statusGray, ), ), ); } }