| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- import 'package:flutter/material.dart';
- import 'package:flutter/services.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 '../../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_footer.dart';
- import '../../core/i18n/app_localizations.dart';
- import 'expense_list_controller.dart';
- import 'expense_model.dart';
- import 'expense_api.dart';
- class ExpenseListPage extends ConsumerStatefulWidget {
- const ExpenseListPage({super.key});
- @override
- ConsumerState<ExpenseListPage> createState() => _ExpenseListPageState();
- }
- class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
- with WidgetsBindingObserver {
- final _keywordCtrl = TextEditingController();
- final _startDateCtrl = TextEditingController();
- final _endDateCtrl = TextEditingController();
- late final EasyRefreshController _refreshCtrl;
- @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')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
- _refreshCtrl = EasyRefreshController();
- WidgetsBinding.instance.addPostFrameCallback((_) {
- ref.read(expenseDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
- ref.read(expenseDateEndProvider.notifier).state = DateTime(now.year, now.month, _daysInMonth(now.year, now.month));
- });
- WidgetsBinding.instance.addObserver(this);
- }
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
- if (state == AppLifecycleState.resumed) {
- _keywordCtrl.clear();
- 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')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
- ref.read(expenseKeywordProvider.notifier).state = '';
- ref.read(expenseDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
- ref.read(expenseDateEndProvider.notifier).state = DateTime(now.year, now.month, _daysInMonth(now.year, now.month));
- ref.read(expenseRefreshProvider.notifier).state++;
- }
- }
- @override
- void dispose() {
- WidgetsBinding.instance.removeObserver(this);
- _keywordCtrl.dispose();
- _startDateCtrl.dispose();
- _endDateCtrl.dispose();
- _refreshCtrl.dispose();
- super.dispose();
- }
- void _pickDate(TextEditingController ctrl) {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final now = DateTime.now();
- TDPicker.showDatePicker(
- context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard,
- useYear: true, useMonth: true, useDay: true,
- useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
- dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31],
- initialDate: [now.year, now.month, now.day],
- onConfirm: (selected) {
- ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
- setState(() {});
- Navigator.of(context).pop();
- },
- );
- }
- int _daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
- void _applyFilter() {
- ref.read(expenseKeywordProvider.notifier).state = _keywordCtrl.text.trim();
- ref.read(expenseDateStartProvider.notifier).state = _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null;
- ref.read(expenseDateEndProvider.notifier).state = _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null;
- _refreshCtrl.callRefresh();
- }
- Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
- final text = ctrl.text;
- return Container(
- height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
- decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
- child: Row(children: [
- Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 8),
- Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
- if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
- ]),
- );
- }
- @override
- Widget build(BuildContext context) {
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final tdTheme = TDTheme.of(context);
- setNavBarTitle(context, ref, NavBarConfig(title: l10n.get('expenseList'), showBack: true, onBack: () => SystemNavigator.pop()));
- return Column(children: [
- Container(
- color: colors.bgCard,
- child: Column(mainAxisSize: MainAxisSize.min, children: [
- Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
- Expanded(child: GestureDetector(onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
- const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
- Expanded(child: GestureDetector(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
- ])),
- const SizedBox(height: 8),
- Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
- Expanded(
- child: Container(
- height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
- decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
- child: Row(children: [
- Expanded(child: TextField(controller: _keywordCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration(hintText: l10n.get('searchExpense'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true), onChanged: (_) => setState(() {}))),
- if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
- ]),
- ),
- ),
- const SizedBox(width: 8),
- GestureDetector(
- onTap: () {
- final dir = ref.read(expenseSortDirProvider);
- ref.read(expenseSortDirProvider.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(expenseSortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
- ),
- const SizedBox(width: 8),
- GestureDetector(onTap: _applyFilter, child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))),
- ])),
- ]),
- ),
- Expanded(child: Container(color: colors.bgPage, child: _ExpenseListContent(refreshCtrl: _refreshCtrl))),
- ]);
- }
- }
- class _ExpenseListContent extends ConsumerWidget {
- final EasyRefreshController refreshCtrl;
- const _ExpenseListContent({required this.refreshCtrl});
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final r = ResponsiveHelper.of(context);
- final itemsAsync = ref.watch(expenseMyListProvider(''));
- if (itemsAsync.isLoading && !itemsAsync.hasValue) return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: const SkeletonLoadingList()));
- return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: () async { ref.read(expenseRefreshProvider.notifier).state++; }, child: _buildContent(itemsAsync, context, ref))));
- }
- Widget _buildContent(AsyncValue<List<ExpenseModel>> itemsAsync, BuildContext context, WidgetRef ref) {
- final l10n = AppLocalizations.of(context);
- 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 card = ListCard(cardNo: oldItems[i].expenseNo, amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}', applicant: oldItems[i].applicantName, description: oldItems[i].purpose, date: du.DateUtils.formatDate(oldItems[i].createTime), statusTag: StatusTag.fromStatus(oldItems[i].status, l10n), onTap: () { context.push('/expense/detail/${oldItems[i].expenseNo}'); });
- 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 card = ListCard(cardNo: items[i].expenseNo, amount: '¥${items[i].totalAmount.toStringAsFixed(2)}', applicant: items[i].applicantName, description: items[i].purpose, date: du.DateUtils.formatDate(items[i].createTime), statusTag: StatusTag.fromStatus(items[i].status, l10n), onTap: () { context.push('/expense/detail/${items[i].expenseNo}'); });
- return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
- });
- }
- }
|