1269 lines
44 KiB
Dart
1269 lines
44 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_redux/flutter_redux.dart';
|
|
import 'package:invoiceninja_flutter/constants.dart';
|
|
import 'package:invoiceninja_flutter/data/models/models.dart';
|
|
import 'package:invoiceninja_flutter/redux/app/app_actions.dart';
|
|
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
|
|
import 'package:invoiceninja_flutter/redux/transaction/transaction_actions.dart';
|
|
import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/buttons/elevated_button.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/entities/entity_list_tile.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/entity_header.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/app_toggle_buttons.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/date_picker.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/decorated_form_field.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/icon_message.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/lists/list_divider.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/search_text.dart';
|
|
import 'package:invoiceninja_flutter/ui/expense/expense_list_item.dart';
|
|
import 'package:invoiceninja_flutter/ui/expense_category/expense_category_list_item.dart';
|
|
import 'package:invoiceninja_flutter/ui/invoice/invoice_list_item.dart';
|
|
import 'package:invoiceninja_flutter/ui/payment/payment_list_item.dart';
|
|
import 'package:invoiceninja_flutter/ui/transaction/transaction_screen.dart';
|
|
import 'package:invoiceninja_flutter/ui/transaction/view/transaction_view_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/view_scaffold.dart';
|
|
import 'package:invoiceninja_flutter/ui/vendor/vendor_list_item.dart';
|
|
import 'package:invoiceninja_flutter/utils/completers.dart';
|
|
import 'package:invoiceninja_flutter/utils/formatting.dart';
|
|
import 'package:invoiceninja_flutter/utils/localization.dart';
|
|
|
|
class TransactionView extends StatefulWidget {
|
|
const TransactionView({
|
|
Key key,
|
|
@required this.viewModel,
|
|
@required this.isFilter,
|
|
}) : super(key: key);
|
|
|
|
final TransactionViewVM viewModel;
|
|
final bool isFilter;
|
|
|
|
@override
|
|
_TransactionViewState createState() => new _TransactionViewState();
|
|
}
|
|
|
|
class _TransactionViewState extends State<TransactionView> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final viewModel = widget.viewModel;
|
|
final transactions = viewModel.transactions;
|
|
final transaction =
|
|
transactions.isEmpty ? TransactionEntity() : transactions.first;
|
|
final localization = AppLocalization.of(context);
|
|
final state = viewModel.state;
|
|
final hasUnconvertable = transactions
|
|
.where((transaction) =>
|
|
!transaction.isWithdrawal || transaction.isConverted)
|
|
.isNotEmpty &&
|
|
transactions.length > 1;
|
|
|
|
return ViewScaffold(
|
|
isFilter: widget.isFilter,
|
|
entity: transaction,
|
|
title: transactions.length > 1
|
|
? '${transactions.length} ${localization.selected}'
|
|
: null,
|
|
body: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: hasUnconvertable
|
|
? []
|
|
: [
|
|
if (transactions.length == 1) ...[
|
|
EntityHeader(
|
|
entity: transaction,
|
|
label: transaction.isDeposit
|
|
? localization.deposit
|
|
: localization.withdrawal,
|
|
value: formatNumber(transaction.amount, context,
|
|
currencyId: transaction.currencyId),
|
|
secondLabel: localization.date,
|
|
secondValue: formatDate(transaction.date, context),
|
|
),
|
|
ListDivider(),
|
|
],
|
|
if (transaction.isConverted) ...[
|
|
if (transaction.description.isNotEmpty) ...[
|
|
IconMessage(transaction.description, copyToClipboard: true),
|
|
ListDivider(),
|
|
],
|
|
EntityListTile(
|
|
entity:
|
|
state.bankAccountState.get(transaction.bankAccountId),
|
|
isFilter: false,
|
|
),
|
|
EntityListTile(
|
|
entity: state.transactionRuleState
|
|
.get(transaction.transactionRuleId),
|
|
isFilter: false,
|
|
),
|
|
if (transaction.isDeposit) ...[
|
|
...transaction.invoiceIds
|
|
.split(',')
|
|
.map((invoiceId) => state.invoiceState.get(invoiceId))
|
|
.map((invoice) =>
|
|
EntityListTile(entity: invoice, isFilter: false)),
|
|
EntityListTile(
|
|
entity: state.paymentState.get(transaction.paymentId),
|
|
isFilter: false,
|
|
),
|
|
] else ...[
|
|
EntityListTile(
|
|
entity: state.vendorState.get(transaction.vendorId),
|
|
isFilter: false,
|
|
),
|
|
EntityListTile(
|
|
entity: state.expenseCategoryState
|
|
.get(transaction.categoryId),
|
|
isFilter: false,
|
|
),
|
|
EntityListTile(
|
|
entity: state.expenseState.get(transaction.expenseId),
|
|
isFilter: false,
|
|
),
|
|
]
|
|
] else ...[
|
|
if (transaction.isDeposit)
|
|
Expanded(
|
|
child: _MatchDeposits(
|
|
viewModel: viewModel,
|
|
),
|
|
)
|
|
else
|
|
Expanded(
|
|
child: _MatchWithdrawals(
|
|
viewModel: viewModel,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MatchDeposits extends StatefulWidget {
|
|
const _MatchDeposits({
|
|
Key key,
|
|
// ignore: unused_element
|
|
@required this.viewModel,
|
|
}) : super(key: key);
|
|
|
|
final TransactionViewVM viewModel;
|
|
|
|
@override
|
|
State<_MatchDeposits> createState() => _MatchDepositsState();
|
|
}
|
|
|
|
class _MatchDepositsState extends State<_MatchDeposits> {
|
|
final _invoiceScrollController = ScrollController();
|
|
final _paymentScrollController = ScrollController();
|
|
TextEditingController _invoiceFilterController;
|
|
TextEditingController _paymentFilterController;
|
|
FocusNode _focusNode;
|
|
List<InvoiceEntity> _invoices;
|
|
List<InvoiceEntity> _selectedInvoices;
|
|
List<PaymentEntity> _payments;
|
|
PaymentEntity _selectedPayment;
|
|
|
|
bool _matchExisting = false;
|
|
bool _showFilter = false;
|
|
String _minAmount = '';
|
|
String _maxAmount = '';
|
|
String _startDate = '';
|
|
String _endDate = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_invoiceFilterController = TextEditingController();
|
|
_paymentFilterController = TextEditingController();
|
|
_focusNode = FocusNode();
|
|
_selectedInvoices = [];
|
|
|
|
final transactions = widget.viewModel.transactions;
|
|
final state = widget.viewModel.state;
|
|
if (transactions.isNotEmpty) {
|
|
_selectedInvoices = transactions.first.invoiceIds
|
|
.split(',')
|
|
.map((invoiceId) => state.invoiceState.map[invoiceId])
|
|
.where((invoice) => invoice != null)
|
|
.toList();
|
|
}
|
|
|
|
updateInvoiceList();
|
|
updatePaymentList();
|
|
}
|
|
|
|
void updateInvoiceList() {
|
|
final state = widget.viewModel.state;
|
|
final invoiceState = state.invoiceState;
|
|
|
|
_invoices = invoiceState.map.values.where((invoice) {
|
|
if (_selectedInvoices.isNotEmpty) {
|
|
if (invoice.clientId != _selectedInvoices.first.clientId) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (invoice.isPaid || invoice.isDeleted) {
|
|
return false;
|
|
}
|
|
|
|
final filter = _invoiceFilterController.text;
|
|
|
|
if (filter.isNotEmpty) {
|
|
final client = state.clientState.get(invoice.clientId);
|
|
if (!invoice.matchesFilter(filter) &&
|
|
!client.matchesNameOrEmail(filter)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_showFilter) {
|
|
if (_minAmount.isNotEmpty) {
|
|
if (invoice.balanceOrAmount < parseDouble(_minAmount)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_maxAmount.isNotEmpty) {
|
|
if (invoice.balanceOrAmount > parseDouble(_maxAmount)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_startDate.isNotEmpty) {
|
|
if (invoice.date.compareTo(_startDate) == -1) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_endDate.isNotEmpty) {
|
|
if (invoice.date.compareTo(_endDate) == 1) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}).toList();
|
|
_invoices.sort((invoiceA, invoiceB) {
|
|
return invoiceB.date.compareTo(invoiceA.date);
|
|
});
|
|
}
|
|
|
|
void updatePaymentList() {
|
|
final state = widget.viewModel.state;
|
|
final paymentState = state.paymentState;
|
|
|
|
_payments = paymentState.map.values.where((payment) {
|
|
if (_selectedPayment != null) {
|
|
if (payment.id != _selectedPayment.id) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (payment.transactionId.isNotEmpty || payment.isDeleted) {
|
|
return false;
|
|
}
|
|
|
|
final filter = _paymentFilterController.text;
|
|
|
|
if (filter.isNotEmpty) {
|
|
final client = state.clientState.get(payment.clientId);
|
|
if (!payment.matchesFilter(filter) &&
|
|
!client.matchesNameOrEmail(filter)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_showFilter) {
|
|
if (_minAmount.isNotEmpty) {
|
|
if (payment.amount < parseDouble(_minAmount)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_maxAmount.isNotEmpty) {
|
|
if (payment.amount > parseDouble(_maxAmount)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_startDate.isNotEmpty) {
|
|
if (payment.date.compareTo(_startDate) == -1) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_endDate.isNotEmpty) {
|
|
if (payment.date.compareTo(_endDate) == 1) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}).toList();
|
|
_payments.sort((paymentA, paymentB) {
|
|
return paymentB.date.compareTo(paymentA.date);
|
|
});
|
|
}
|
|
|
|
bool get isFiltered {
|
|
if (_minAmount.isNotEmpty || _maxAmount.isNotEmpty) {
|
|
return true;
|
|
}
|
|
|
|
if (_startDate.isNotEmpty || _endDate.isNotEmpty) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_invoiceScrollController.dispose();
|
|
_paymentScrollController.dispose();
|
|
_invoiceFilterController.dispose();
|
|
_paymentFilterController.dispose();
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final localization = AppLocalization.of(context);
|
|
final viewModel = widget.viewModel;
|
|
final state = viewModel.state;
|
|
|
|
String currencyId;
|
|
if (_selectedInvoices.isNotEmpty) {
|
|
currencyId =
|
|
state.clientState.get(_selectedInvoices.first.clientId).currencyId;
|
|
}
|
|
|
|
double totalSelected = 0;
|
|
_selectedInvoices.forEach((invoice) {
|
|
totalSelected += invoice.balanceOrAmount;
|
|
});
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (viewModel.transactions.length == 1) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(
|
|
child: AppToggleButtons(
|
|
padding: 0,
|
|
onTabChanged: (value) =>
|
|
setState(() => _matchExisting = value == 1),
|
|
selectedIndex: _matchExisting ? 1 : 0,
|
|
tabLabels: [
|
|
localization.createPayment,
|
|
localization.linkPayment,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
ListDivider(),
|
|
],
|
|
if (_matchExisting)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 22, top: 12, right: 10, bottom: 12),
|
|
child: SearchText(
|
|
filterController: _paymentFilterController,
|
|
focusNode: _focusNode,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
updatePaymentList();
|
|
});
|
|
},
|
|
onCleared: () {
|
|
setState(() {
|
|
_paymentFilterController.text = '';
|
|
updatePaymentList();
|
|
});
|
|
},
|
|
placeholder:
|
|
localization.searchPayments.replaceFirst(':count ', ''),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
setState(() => _showFilter = !_showFilter);
|
|
},
|
|
color: _showFilter || isFiltered ? state.accentColor : null,
|
|
icon: Icon(Icons.filter_alt),
|
|
tooltip:
|
|
state.prefState.enableTooltips ? localization.filter : '',
|
|
),
|
|
SizedBox(width: 8),
|
|
],
|
|
)
|
|
else
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 22, top: 12, right: 10, bottom: 12),
|
|
child: SearchText(
|
|
filterController: _invoiceFilterController,
|
|
focusNode: _focusNode,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
updateInvoiceList();
|
|
});
|
|
},
|
|
onCleared: () {
|
|
setState(() {
|
|
_invoiceFilterController.text = '';
|
|
updateInvoiceList();
|
|
});
|
|
},
|
|
placeholder:
|
|
localization.searchInvoices.replaceFirst(':count ', ''),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
setState(() => _showFilter = !_showFilter);
|
|
},
|
|
color: _showFilter || isFiltered ? state.accentColor : null,
|
|
icon: Icon(Icons.filter_alt),
|
|
tooltip:
|
|
state.prefState.enableTooltips ? localization.filter : '',
|
|
),
|
|
SizedBox(width: 8),
|
|
],
|
|
),
|
|
ListDivider(),
|
|
AnimatedContainer(
|
|
duration: Duration(milliseconds: 200),
|
|
height: _showFilter ? 138 : 0,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: DecoratedFormField(
|
|
label: localization.minAmount,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_minAmount = value;
|
|
updateInvoiceList();
|
|
});
|
|
},
|
|
keyboardType:
|
|
TextInputType.numberWithOptions(decimal: true),
|
|
)),
|
|
SizedBox(
|
|
width: kTableColumnGap,
|
|
),
|
|
Expanded(
|
|
child: DecoratedFormField(
|
|
label: localization.maxAmount,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_maxAmount = value;
|
|
updateInvoiceList();
|
|
});
|
|
},
|
|
keyboardType:
|
|
TextInputType.numberWithOptions(decimal: true),
|
|
)),
|
|
],
|
|
),
|
|
Row(children: [
|
|
Expanded(
|
|
child: DatePicker(
|
|
labelText: localization.startDate,
|
|
onSelected: (date, _) {
|
|
setState(() {
|
|
_startDate = date;
|
|
updateInvoiceList();
|
|
});
|
|
},
|
|
selectedDate: _startDate,
|
|
),
|
|
),
|
|
SizedBox(width: kTableColumnGap),
|
|
Expanded(
|
|
child: DatePicker(
|
|
labelText: localization.endDate,
|
|
onSelected: (date, _) {
|
|
setState(() {
|
|
_endDate = date;
|
|
updateInvoiceList();
|
|
});
|
|
},
|
|
selectedDate: _endDate,
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
ListDivider(),
|
|
],
|
|
),
|
|
),
|
|
if (_matchExisting) ...[
|
|
Expanded(
|
|
child: Scrollbar(
|
|
thumbVisibility: true,
|
|
controller: _paymentScrollController,
|
|
child: ListView.separated(
|
|
controller: _paymentScrollController,
|
|
separatorBuilder: (context, index) => ListDivider(),
|
|
itemCount: _payments.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final payment = _payments[index];
|
|
return PaymentListItem(
|
|
payment: payment,
|
|
showCheckbox: true,
|
|
showSelected: false,
|
|
isChecked: (_selectedPayment?.id ?? '') == payment.id,
|
|
onTap: () => setState(() {
|
|
if ((_selectedPayment?.id ?? '') == payment.id) {
|
|
_selectedPayment = null;
|
|
} else {
|
|
_selectedPayment = payment;
|
|
}
|
|
updatePaymentList();
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
] else ...[
|
|
Expanded(
|
|
child: Scrollbar(
|
|
thumbVisibility: true,
|
|
controller: _invoiceScrollController,
|
|
child: ListView.separated(
|
|
controller: _invoiceScrollController,
|
|
separatorBuilder: (context, index) => ListDivider(),
|
|
itemCount: _invoices.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final invoice = _invoices[index];
|
|
return InvoiceListItem(
|
|
invoice: invoice,
|
|
showCheckbox: true,
|
|
showSelected: false,
|
|
isChecked: _selectedInvoices.contains(invoice),
|
|
onTap: () => setState(() {
|
|
if (_selectedInvoices.contains(invoice)) {
|
|
_selectedInvoices.remove(invoice);
|
|
} else {
|
|
_selectedInvoices.add(invoice);
|
|
}
|
|
updateInvoiceList();
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
if (_selectedInvoices.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Text(
|
|
'${_selectedInvoices.length} ${localization.selected} • ${formatNumber(totalSelected, context, currencyId: currencyId)}',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
),
|
|
],
|
|
ListDivider(),
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 20,
|
|
bottom: 18,
|
|
right: 20,
|
|
),
|
|
child: _matchExisting
|
|
? AppButton(
|
|
label: localization.linkPayment,
|
|
onPressed:
|
|
_selectedPayment == null || viewModel.state.isSaving
|
|
? null
|
|
: () {
|
|
final viewModel = widget.viewModel;
|
|
viewModel.onLinkToPayment(
|
|
context,
|
|
_selectedPayment.id,
|
|
);
|
|
},
|
|
iconData: Icons.link,
|
|
)
|
|
: AppButton(
|
|
label: localization.createPayment,
|
|
onPressed:
|
|
_selectedInvoices.isEmpty || viewModel.state.isSaving
|
|
? null
|
|
: () {
|
|
final viewModel = widget.viewModel;
|
|
viewModel.onConvertToPayment(
|
|
context,
|
|
_selectedInvoices
|
|
.map((invoice) => invoice.id)
|
|
.toList(),
|
|
);
|
|
},
|
|
iconData: Icons.add,
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MatchWithdrawals extends StatefulWidget {
|
|
const _MatchWithdrawals({
|
|
Key key,
|
|
@required this.viewModel,
|
|
}) : super(key: key);
|
|
|
|
final TransactionViewVM viewModel;
|
|
|
|
@override
|
|
State<_MatchWithdrawals> createState() => _MatchWithdrawalsState();
|
|
}
|
|
|
|
class _MatchWithdrawalsState extends State<_MatchWithdrawals> {
|
|
final _vendorScrollController = ScrollController();
|
|
final _categoryScrollController = ScrollController();
|
|
final _expenseScrollController = ScrollController();
|
|
|
|
bool _matchExisting = false;
|
|
bool _showFilter = false;
|
|
String _minAmount = '';
|
|
String _maxAmount = '';
|
|
String _startDate = '';
|
|
String _endDate = '';
|
|
|
|
TextEditingController _vendorFilterController;
|
|
TextEditingController _categoryFilterController;
|
|
TextEditingController _expenseFilterController;
|
|
FocusNode _vendorFocusNode;
|
|
FocusNode _categoryFocusNode;
|
|
FocusNode _expenseFocusNode;
|
|
List<VendorEntity> _vendors;
|
|
List<ExpenseCategoryEntity> _categories;
|
|
List<ExpenseEntity> _expenses;
|
|
VendorEntity _selectedVendor;
|
|
ExpenseCategoryEntity _selectedCategory;
|
|
ExpenseEntity _selectedExpense;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_vendorFilterController = TextEditingController();
|
|
_categoryFilterController = TextEditingController();
|
|
_expenseFilterController = TextEditingController();
|
|
|
|
_vendorFocusNode = FocusNode();
|
|
_categoryFocusNode = FocusNode();
|
|
_expenseFocusNode = FocusNode();
|
|
|
|
final transactions = widget.viewModel.transactions;
|
|
final state = widget.viewModel.state;
|
|
if (transactions.isNotEmpty) {
|
|
final transaction = transactions.first;
|
|
if ((transaction.pendingCategoryId ?? '').isNotEmpty) {
|
|
_selectedCategory =
|
|
state.expenseCategoryState.get(transaction.pendingCategoryId);
|
|
} else if ((transaction.categoryId ?? '').isNotEmpty) {
|
|
_selectedCategory =
|
|
state.expenseCategoryState.get(transaction.categoryId);
|
|
}
|
|
|
|
if ((transaction.pendingVendorId ?? '').isNotEmpty) {
|
|
_selectedVendor = state.vendorState.get(transaction.pendingVendorId);
|
|
} else if ((transaction.vendorId ?? '').isNotEmpty) {
|
|
_selectedVendor = state.vendorState.get(transaction.vendorId);
|
|
}
|
|
}
|
|
|
|
updateVendorList();
|
|
updateCategoryList();
|
|
updateExpenseList();
|
|
}
|
|
|
|
void updateCategoryList() {
|
|
final state = widget.viewModel.state;
|
|
final categoryState = state.expenseCategoryState;
|
|
|
|
_categories = categoryState.map.values.where((category) {
|
|
if (_selectedCategory != null) {
|
|
if (category.id != _selectedCategory?.id) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (category.isDeleted) {
|
|
return false;
|
|
}
|
|
|
|
final filter = _categoryFilterController.text;
|
|
|
|
if (filter.isNotEmpty) {
|
|
if (!category.matchesFilter(filter)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}).toList();
|
|
_categories.sort((categoryA, categoryB) {
|
|
return categoryA.name
|
|
.toLowerCase()
|
|
.compareTo(categoryB.name.toLowerCase());
|
|
});
|
|
}
|
|
|
|
void updateVendorList() {
|
|
final state = widget.viewModel.state;
|
|
final vendorState = state.vendorState;
|
|
|
|
_vendors = vendorState.map.values.where((vendor) {
|
|
if (_selectedVendor != null) {
|
|
if (vendor.id != _selectedVendor?.id) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (vendor.isDeleted) {
|
|
return false;
|
|
}
|
|
|
|
final filter = _vendorFilterController.text;
|
|
|
|
if (filter.isNotEmpty) {
|
|
if (!vendor.matchesFilter(filter)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}).toList();
|
|
_vendors.sort((vendorA, vendorB) {
|
|
return vendorA.name.toLowerCase().compareTo(vendorB.name.toLowerCase());
|
|
});
|
|
}
|
|
|
|
void updateExpenseList() {
|
|
final state = widget.viewModel.state;
|
|
final expenseState = state.expenseState;
|
|
|
|
_expenses = expenseState.map.values.where((expense) {
|
|
if (_selectedExpense != null) {
|
|
if (expense.id != _selectedExpense.id) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (expense.transactionId.isNotEmpty || expense.isDeleted) {
|
|
return false;
|
|
}
|
|
|
|
final filter = _expenseFilterController.text;
|
|
|
|
if (filter.isNotEmpty) {
|
|
final client = state.clientState.get(expense.clientId);
|
|
final vendor = state.vendorState.get(expense.vendorId);
|
|
if (!expense.matchesFilter(filter) &&
|
|
!client.matchesNameOrEmail(filter) &&
|
|
!vendor.matchesNameOrEmail(filter)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_showFilter) {
|
|
if (_minAmount.isNotEmpty) {
|
|
if (expense.amount < parseDouble(_minAmount)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_maxAmount.isNotEmpty) {
|
|
if (expense.amount > parseDouble(_maxAmount)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_startDate.isNotEmpty) {
|
|
if (expense.date.compareTo(_startDate) == -1) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (_endDate.isNotEmpty) {
|
|
if (expense.date.compareTo(_endDate) == 1) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}).toList();
|
|
_expenses.sort((expenseA, expenseB) {
|
|
return expenseB.date.compareTo(expenseA.date);
|
|
});
|
|
}
|
|
|
|
bool get isFiltered {
|
|
if (_minAmount.isNotEmpty || _maxAmount.isNotEmpty) {
|
|
return true;
|
|
}
|
|
|
|
if (_startDate.isNotEmpty || _endDate.isNotEmpty) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_vendorFilterController.dispose();
|
|
_categoryFilterController.dispose();
|
|
_expenseFilterController.dispose();
|
|
|
|
_vendorFocusNode.dispose();
|
|
_categoryFocusNode.dispose();
|
|
_expenseFocusNode.dispose();
|
|
|
|
_vendorScrollController.dispose();
|
|
_categoryScrollController.dispose();
|
|
_expenseScrollController.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final localization = AppLocalization.of(context);
|
|
final store = StoreProvider.of<AppState>(context);
|
|
final viewModel = widget.viewModel;
|
|
final state = viewModel.state;
|
|
final transactions = viewModel.transactions;
|
|
final transaction =
|
|
transactions.isNotEmpty ? transactions.first : TransactionEntity();
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (viewModel.transactions.length == 1) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(
|
|
child: AppToggleButtons(
|
|
padding: 0,
|
|
onTabChanged: (value) =>
|
|
setState(() => _matchExisting = value == 1),
|
|
selectedIndex: _matchExisting ? 1 : 0,
|
|
tabLabels: [
|
|
localization.createExpense,
|
|
localization.linkExpense,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
ListDivider(),
|
|
if (_matchExisting) ...[
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 22, top: 12, right: 10, bottom: 12),
|
|
child: SearchText(
|
|
filterController: _expenseFilterController,
|
|
focusNode: _expenseFocusNode,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
updateExpenseList();
|
|
});
|
|
},
|
|
onCleared: () {
|
|
setState(() {
|
|
_expenseFilterController.text = '';
|
|
updateExpenseList();
|
|
});
|
|
},
|
|
placeholder:
|
|
localization.searchExpenses.replaceFirst(':count ', ''),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
setState(() => _showFilter = !_showFilter);
|
|
},
|
|
color: _showFilter || isFiltered ? state.accentColor : null,
|
|
icon: Icon(Icons.filter_alt),
|
|
tooltip:
|
|
state.prefState.enableTooltips ? localization.filter : '',
|
|
),
|
|
SizedBox(width: 8),
|
|
],
|
|
),
|
|
ListDivider(),
|
|
AnimatedContainer(
|
|
duration: Duration(milliseconds: 200),
|
|
height: _showFilter ? 138 : 0,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: DecoratedFormField(
|
|
label: localization.minAmount,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_minAmount = value;
|
|
updateExpenseList();
|
|
});
|
|
},
|
|
keyboardType: TextInputType.numberWithOptions(
|
|
decimal: true),
|
|
)),
|
|
SizedBox(
|
|
width: kTableColumnGap,
|
|
),
|
|
Expanded(
|
|
child: DecoratedFormField(
|
|
label: localization.maxAmount,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_maxAmount = value;
|
|
updateExpenseList();
|
|
});
|
|
},
|
|
keyboardType: TextInputType.numberWithOptions(
|
|
decimal: true),
|
|
)),
|
|
],
|
|
),
|
|
Row(children: [
|
|
Expanded(
|
|
child: DatePicker(
|
|
labelText: localization.startDate,
|
|
onSelected: (date, _) {
|
|
setState(() {
|
|
_startDate = date;
|
|
updateExpenseList();
|
|
});
|
|
},
|
|
selectedDate: _startDate,
|
|
),
|
|
),
|
|
SizedBox(width: kTableColumnGap),
|
|
Expanded(
|
|
child: DatePicker(
|
|
labelText: localization.endDate,
|
|
onSelected: (date, _) {
|
|
setState(() {
|
|
_endDate = date;
|
|
updateExpenseList();
|
|
});
|
|
},
|
|
selectedDate: _endDate,
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
ListDivider(),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Scrollbar(
|
|
thumbVisibility: true,
|
|
controller: _expenseScrollController,
|
|
child: ListView.separated(
|
|
controller: _expenseScrollController,
|
|
separatorBuilder: (context, index) => ListDivider(),
|
|
itemCount: _expenses.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final expense = _expenses[index];
|
|
return ExpenseListItem(
|
|
expense: expense,
|
|
showCheckbox: true,
|
|
showSelected: false,
|
|
isChecked: _selectedExpense?.id == expense.id,
|
|
onTap: () => setState(() {
|
|
if (_selectedExpense?.id == expense.id) {
|
|
_selectedExpense = null;
|
|
} else {
|
|
_selectedExpense = expense;
|
|
}
|
|
updateExpenseList();
|
|
store.dispatch(SaveTransactionSuccess(transaction.rebuild(
|
|
(b) => b..pendingExpenseId = _selectedExpense?.id)));
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
] else
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 22, top: 12, right: 10, bottom: 12),
|
|
child: SearchText(
|
|
filterController: _vendorFilterController,
|
|
focusNode: _vendorFocusNode,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
updateVendorList();
|
|
});
|
|
},
|
|
onCleared: () {
|
|
setState(() {
|
|
_vendorFilterController.text = '';
|
|
updateVendorList();
|
|
});
|
|
},
|
|
placeholder: localization.searchVendors
|
|
.replaceFirst(':count ', '')),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
final completer = snackBarCompleter<VendorEntity>(
|
|
context, localization.createdVendor);
|
|
createEntity(
|
|
context: context,
|
|
entity: VendorEntity(state: viewModel.state),
|
|
force: true,
|
|
completer: completer,
|
|
cancelCompleter: Completer<Null>()
|
|
..future.then((_) {
|
|
store.dispatch(UpdateCurrentRoute(
|
|
TransactionScreen.route));
|
|
}));
|
|
completer.future.then((SelectableEntity vendor) {
|
|
store.dispatch(SaveTransactionSuccess(transaction
|
|
.rebuild((b) => b..pendingVendorId = vendor.id)));
|
|
store.dispatch(
|
|
UpdateCurrentRoute(TransactionScreen.route));
|
|
});
|
|
},
|
|
icon: Icon(Icons.add),
|
|
),
|
|
SizedBox(width: 8),
|
|
],
|
|
),
|
|
ListDivider(),
|
|
Expanded(
|
|
child: Scrollbar(
|
|
thumbVisibility: true,
|
|
controller: _vendorScrollController,
|
|
child: ListView.separated(
|
|
controller: _vendorScrollController,
|
|
separatorBuilder: (context, index) => ListDivider(),
|
|
itemCount: _vendors.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final vendor = _vendors[index];
|
|
return VendorListItem(
|
|
vendor: vendor,
|
|
showCheck: true,
|
|
isChecked: _selectedVendor?.id == vendor.id,
|
|
onTap: () => setState(() {
|
|
if (_selectedVendor?.id == vendor.id) {
|
|
_selectedVendor = null;
|
|
} else {
|
|
_selectedVendor = vendor;
|
|
}
|
|
updateVendorList();
|
|
store.dispatch(SaveTransactionSuccess(
|
|
transaction.rebuild((b) =>
|
|
b..pendingVendorId = _selectedVendor?.id)));
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
ListDivider(),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 22, top: 12, right: 10, bottom: 12),
|
|
child: SearchText(
|
|
filterController: _categoryFilterController,
|
|
focusNode: _categoryFocusNode,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
updateCategoryList();
|
|
});
|
|
},
|
|
onCleared: () {
|
|
setState(() {
|
|
_categoryFilterController.text = '';
|
|
updateCategoryList();
|
|
});
|
|
},
|
|
placeholder: localization.searchCategories
|
|
.replaceFirst(':count ', '')),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () {
|
|
final completer =
|
|
snackBarCompleter<ExpenseCategoryEntity>(
|
|
context, localization.createdExpenseCategory);
|
|
createEntity(
|
|
context: context,
|
|
entity:
|
|
ExpenseCategoryEntity(state: viewModel.state),
|
|
force: true,
|
|
completer: completer,
|
|
cancelCompleter: Completer<Null>()
|
|
..future.then((_) {
|
|
store.dispatch(UpdateCurrentRoute(
|
|
TransactionScreen.route));
|
|
}));
|
|
completer.future.then((SelectableEntity category) {
|
|
store.dispatch(SaveTransactionSuccess(
|
|
transaction.rebuild(
|
|
(b) => b..pendingCategoryId = category.id)));
|
|
store.dispatch(
|
|
UpdateCurrentRoute(TransactionScreen.route));
|
|
});
|
|
},
|
|
icon: Icon(Icons.add),
|
|
),
|
|
SizedBox(width: 8),
|
|
],
|
|
),
|
|
ListDivider(),
|
|
Expanded(
|
|
child: Scrollbar(
|
|
thumbVisibility: true,
|
|
controller: _categoryScrollController,
|
|
child: ListView.separated(
|
|
controller: _categoryScrollController,
|
|
separatorBuilder: (context, index) => ListDivider(),
|
|
itemCount: _categories.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final category = _categories[index];
|
|
return ExpenseCategoryListItem(
|
|
expenseCategory: category,
|
|
showCheck: true,
|
|
isChecked: _selectedCategory?.id == category.id,
|
|
onTap: () => setState(() {
|
|
if (_selectedCategory?.id == category.id) {
|
|
_selectedCategory = null;
|
|
} else {
|
|
_selectedCategory = category;
|
|
}
|
|
updateCategoryList();
|
|
store.dispatch(SaveTransactionSuccess(
|
|
transaction.rebuild((b) => b
|
|
..pendingCategoryId =
|
|
_selectedCategory?.id)));
|
|
}),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
if (transaction.category.isNotEmpty &&
|
|
_selectedCategory == null)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16, vertical: 12),
|
|
child: Text(
|
|
'${localization.defaultCategory}: ${transaction.category}',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
ListDivider(),
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 20,
|
|
bottom: 16,
|
|
right: 20,
|
|
),
|
|
child: _matchExisting
|
|
? AppButton(
|
|
label: localization.linkExpense,
|
|
onPressed:
|
|
_selectedExpense == null || viewModel.state.isSaving
|
|
? null
|
|
: () {
|
|
final viewModel = widget.viewModel;
|
|
viewModel.onLinkToExpense(
|
|
context,
|
|
_selectedExpense?.id ?? '',
|
|
);
|
|
},
|
|
iconData: Icons.link,
|
|
)
|
|
: AppButton(
|
|
label: localization.createExpense,
|
|
onPressed: viewModel.state.isSaving
|
|
? null
|
|
: () {
|
|
final viewModel = widget.viewModel;
|
|
viewModel.onConvertToExpense(
|
|
context,
|
|
_selectedVendor?.id ?? '',
|
|
_selectedCategory?.id ?? '',
|
|
);
|
|
},
|
|
iconData: Icons.add,
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|