| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452 |
- 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 '../../shared/widgets/nav_bar_config.dart';
- import '../../core/theme/app_colors_extension.dart';
- import '../../core/utils/date_utils.dart' as du;
- import '../../core/utils/responsive.dart';
- import '../../core/auth/role_provider.dart';
- import '../../shared/widgets/list_card.dart';
- import '../../shared/widgets/status_tag.dart';
- import '../../shared/widgets/empty_state.dart';
- import '../../shared/widgets/skeleton_list_card.dart';
- import '../../shared/widgets/list_filter_panel.dart';
- import '../../shared/widgets/list_footer.dart';
- import '../../core/i18n/app_localizations.dart';
- import 'expense_list_controller.dart';
- import 'expense_model.dart';
- final _scopeProvider = StateProvider<String>((ref) => 'my');
- class ExpenseListPage extends ConsumerStatefulWidget {
- const ExpenseListPage({super.key});
- @override
- ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
- }
- class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
- with TickerProviderStateMixin {
- List<String> _getTabLabels(AppLocalizations l10n) => [
- l10n.get('all'),
- l10n.get('draft'),
- l10n.get('pending'),
- l10n.get('approved'),
- l10n.get('statusWaitPay'),
- l10n.get('paid'),
- l10n.get('rejected'),
- l10n.get('withdrawn'),
- ];
- static const _tabKeys = [
- '',
- 'draft',
- 'pending',
- 'approved',
- 'unpaid',
- 'paid',
- 'rejected',
- 'withdrawn',
- ];
- late final TabController _tabCtrl;
- @override
- void initState() {
- super.initState();
- _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
- _tabCtrl.addListener(() {
- if (!_tabCtrl.indexIsChanging) {
- ref.read(expenseStatusFilterProvider.notifier).state =
- _tabKeys[_tabCtrl.index];
- }
- });
- }
- @override
- void dispose() {
- _tabCtrl.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final status = ref.watch(expenseStatusFilterProvider);
- final dateStart = ref.watch(expenseDateStartProvider);
- final dateEnd = ref.watch(expenseDateEndProvider);
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final isManager = ref.watch(isManagerProvider);
- // Sync TabController with external filter changes
- final targetIdx = _tabKeys.indexOf(status);
- if (targetIdx >= 0 &&
- _tabCtrl.index != targetIdx &&
- !_tabCtrl.indexIsChanging) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) _tabCtrl.animateTo(targetIdx);
- });
- }
- final filterGroups = [
- FilterGroup(
- title: l10n.get('filterDateRange'),
- type: FilterGroupType.dateRange,
- sections: [
- FilterSection(
- label: l10n.get('filterDateRange'),
- type: FilterSectionType.dateRange,
- startDate: dateStart,
- endDate: dateEnd,
- onStartChanged: (v) =>
- ref.read(expenseDateStartProvider.notifier).state = v,
- onEndChanged: (v) =>
- ref.read(expenseDateEndProvider.notifier).state = v,
- ),
- ],
- ),
- ];
- final hasFilter = ListFilterPanel.hasActiveFilter(filterGroups);
- final filterVersion = Object.hash(dateStart, dateEnd);
- final now = DateTime.now();
- void onFilterReset() {
- ref.read(expenseDateStartProvider.notifier).state = null;
- ref.read(expenseDateEndProvider.notifier).state = null;
- }
- ref
- .read(navBarConfigProvider.notifier)
- .update(
- NavBarConfig(
- title: l10n.get('expenseList'),
- showBack: true,
- showRight: true,
- rightWidget: GestureDetector(
- onTap: () => ListFilterPanel.show(
- context,
- groups: filterGroups,
- onReset: onFilterReset,
- onConfirm: () {},
- defaultStartDate: DateTime(now.year, now.month, 1),
- defaultEndDate: DateTime(now.year, now.month, now.day),
- ),
- child: Stack(
- clipBehavior: Clip.none,
- children: [
- Icon(
- TDIcons.filter,
- size: 22,
- color: hasFilter ? colors.primary : colors.textPrimary,
- ),
- if (hasFilter)
- Positioned(
- right: -2,
- top: -2,
- child: Container(
- width: 6,
- height: 6,
- decoration: BoxDecoration(
- color: colors.danger,
- shape: BoxShape.circle,
- ),
- ),
- ),
- ],
- ),
- ),
- hasFilter: hasFilter,
- filterVersion: filterVersion,
- onBack: () => context.pop(),
- ),
- );
- return Column(
- children: [
- if (isManager)
- Container(
- color: colors.bgCard,
- padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
- child: _buildScopeChip(colors),
- ),
- Container(
- color: colors.bgCard,
- padding: EdgeInsets.zero,
- child: TDSearchBar(
- placeHolder: l10n.get('searchExpense'),
- needCancel: true,
- style: TDSearchStyle.round,
- ),
- ),
- Container(
- color: colors.bgCard,
- padding: const EdgeInsets.symmetric(horizontal: 8),
- child: TDTabBar(
- tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(),
- controller: _tabCtrl,
- isScrollable: true,
- labelColor: colors.primary,
- unselectedLabelColor: colors.textSecondary,
- outlineType: TDTabBarOutlineType.filled,
- showIndicator: true,
- indicatorColor: colors.primary,
- indicatorHeight: 3,
- dividerHeight: 0,
- labelPadding: const EdgeInsets.symmetric(horizontal: 12),
- onTap: (index) {
- ref.read(expenseStatusFilterProvider.notifier).state =
- _tabKeys[index];
- },
- ),
- ),
- Expanded(
- child: Container(
- color: colors.bgPage,
- child: TabBarView(
- controller: _tabCtrl,
- children: List.generate(_tabKeys.length, (tabIdx) {
- return _buildTabContent(tabIdx);
- }),
- ),
- ),
- ),
- ],
- );
- }
- Widget _buildScopeChip(AppColorsExtension colors) {
- final scope = ref.watch(_scopeProvider);
- final l10n = AppLocalizations.of(context);
- return Row(
- children: [
- GestureDetector(
- onTap: () => ref.read(_scopeProvider.notifier).state = 'my',
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
- decoration: BoxDecoration(
- color: scope == 'my' ? colors.primary : colors.bgPage,
- borderRadius: BorderRadius.circular(16),
- border: scope == 'my' ? null : Border.all(color: colors.border),
- ),
- child: Text(
- l10n.get('scopeMyApplications'),
- style: TextStyle(
- fontSize: 13,
- color: scope == 'my' ? colors.bgCard : colors.textSecondary,
- ),
- ),
- ),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: () => ref.read(_scopeProvider.notifier).state = 'sub',
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
- decoration: BoxDecoration(
- color: scope == 'sub' ? colors.primary : colors.bgPage,
- borderRadius: BorderRadius.circular(16),
- border: scope == 'sub' ? null : Border.all(color: colors.border),
- ),
- child: Text(
- l10n.get('scopeSubordinates'),
- style: TextStyle(
- fontSize: 13,
- color: scope == 'sub' ? colors.bgCard : colors.textSecondary,
- ),
- ),
- ),
- ),
- ],
- );
- }
- Widget _buildTabContent(int tabIdx) {
- final r = ResponsiveHelper.of(context);
- return Center(
- child: ConstrainedBox(
- constraints: BoxConstraints(maxWidth: r.listMaxWidth),
- child: _ExpenseTabContent(statusKey: _tabKeys[tabIdx]),
- ),
- );
- }
- }
- class _ExpenseTabContent extends ConsumerWidget {
- final String statusKey;
- const _ExpenseTabContent({required this.statusKey});
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final itemsAsync = ref.watch(expenseListProvider(statusKey));
- final scope = ref.watch(_scopeProvider);
- if (itemsAsync.isLoading && !itemsAsync.hasValue) {
- return const SkeletonLoadingList();
- }
- return EasyRefresh(
- header: TDRefreshHeader(),
- onRefresh: () async {
- ref.read(expenseRefreshProvider.notifier).state++;
- },
- child: _buildContent(itemsAsync, context, ref, scope),
- );
- }
- Widget _buildContent(
- AsyncValue<List<ExpenseModel>> itemsAsync,
- BuildContext context,
- WidgetRef ref,
- String scope,
- ) {
- final l10n = AppLocalizations.of(context);
- final isSub = scope == 'sub';
- if (itemsAsync.isReloading) {
- final oldItems = itemsAsync.valueOrNull ?? [];
- if (oldItems.isEmpty) {
- return ListView(
- children: [
- const SizedBox(height: 120),
- EmptyState(message: l10n.get('noExpenses')),
- ],
- );
- }
- return ListView.builder(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
- itemCount: oldItems.length,
- itemBuilder: (_, i) {
- final desc = isSub
- ? '${oldItems[i].expenseType} — ${oldItems[i].applicantName}\n申请人: ${oldItems[i].applicantName} · ${oldItems[i].deptName}'
- : '${oldItems[i].expenseType} — ${oldItems[i].applicantName}';
- final card = ListCard(
- cardNo: oldItems[i].reportNo,
- amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}',
- description: desc,
- date: du.DateUtils.formatDate(oldItems[i].createTime),
- statusTag: StatusTag.fromStatus(oldItems[i].status, l10n),
- onTap: () => context.push('/expense/detail/${oldItems[i].id}'),
- );
- if (isSub && oldItems[i].status == 'pending') {
- return Padding(
- padding: const EdgeInsets.only(bottom: 16),
- child: _buildSwipeApprove(card, oldItems[i].id),
- );
- }
- 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('noExpenses')),
- ],
- );
- }
- return ListView.builder(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
- itemCount: items.length + 1,
- itemBuilder: (_, i) {
- if (i == items.length) return ListFooter(itemCount: items.length);
- final desc = isSub
- ? '${items[i].expenseType} — ${items[i].applicantName}\n申请人: ${items[i].applicantName} · ${items[i].deptName}'
- : '${items[i].expenseType} — ${items[i].applicantName}';
- final card = ListCard(
- cardNo: items[i].reportNo,
- amount: '¥${items[i].totalAmount.toStringAsFixed(2)}',
- description: desc,
- date: du.DateUtils.formatDate(items[i].createTime),
- statusTag: StatusTag.fromStatus(items[i].status, l10n),
- onTap: () => context.push('/expense/detail/${items[i].id}'),
- );
- if (isSub && items[i].status == 'pending') {
- return Padding(
- padding: const EdgeInsets.only(bottom: 16),
- child: _buildSwipeApprove(card, items[i].id),
- );
- }
- return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
- },
- );
- }
- Widget _buildSwipeApprove(Widget card, String itemId) {
- return Builder(
- builder: (ctx) {
- final screenWidth = MediaQuery.of(ctx).size.width;
- return TDSwipeCell(
- groupTag: 'expense_approve',
- right: TDSwipeCellPanel(
- extentRatio: 100 / screenWidth,
- children: [
- TDSwipeCellAction(
- label: '',
- backgroundColor: Colors.transparent,
- builder: (_) => Container(
- margin: const EdgeInsets.symmetric(
- horizontal: 4,
- vertical: 8,
- ),
- decoration: BoxDecoration(
- color: Colors.green,
- borderRadius: BorderRadius.circular(8),
- ),
- alignment: Alignment.center,
- padding: const EdgeInsets.symmetric(horizontal: 12),
- child: const Text(
- '一键同意',
- style: TextStyle(
- color: Colors.white,
- fontSize: 14,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- onPressed: (_) async {
- final confirmed = await showDialog<bool>(
- context: ctx,
- builder: (dCtx) => TDAlertDialog(
- title: '确认审批',
- content: '确认同意该报销?',
- leftBtn: TDDialogButtonOptions(
- title: '取消',
- action: () => Navigator.of(dCtx).pop(false),
- ),
- rightBtn: TDDialogButtonOptions(
- title: '确认',
- action: () => Navigator.of(dCtx).pop(true),
- ),
- ),
- );
- if (confirmed == true) {
- // TODO: 接入实际审批 API
- if (ctx.mounted) {
- TDToast.showSuccess('已审批通过', context: ctx);
- }
- }
- },
- ),
- ],
- ),
- cell: card,
- );
- },
- );
- }
- }
|