1149 lines
51 KiB
Dart
1149 lines
51 KiB
Dart
// Dart imports:
|
|
import 'dart:convert';
|
|
|
|
// Flutter imports:
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
// Package imports:
|
|
import 'package:flutter_redux/flutter_redux.dart';
|
|
import 'package:invoiceninja_flutter/data/models/vendor_model.dart';
|
|
import 'package:invoiceninja_flutter/redux/vendor/vendor_actions.dart';
|
|
import 'package:invoiceninja_flutter/redux/vendor/vendor_selectors.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/buttons/elevated_button.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/entity_dropdown.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
// Project imports:
|
|
import 'package:invoiceninja_flutter/constants.dart';
|
|
import 'package:invoiceninja_flutter/data/models/entities.dart';
|
|
import 'package:invoiceninja_flutter/data/models/invoice_model.dart';
|
|
import 'package:invoiceninja_flutter/data/models/serializers.dart';
|
|
import 'package:invoiceninja_flutter/data/models/settings_model.dart';
|
|
import 'package:invoiceninja_flutter/data/web_client.dart';
|
|
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
|
|
import 'package:invoiceninja_flutter/redux/client/client_selectors.dart';
|
|
import 'package:invoiceninja_flutter/redux/invoice/invoice_selectors.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/form_card.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/app_dropdown_button.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/app_tab_bar.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/client_picker.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/custom_field.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/custom_surcharges.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/forms/design_picker.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/discount_field.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/project_picker.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/user_picker.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/forms/vendor_picker.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/invoice/tax_rate_dropdown.dart';
|
|
import 'package:invoiceninja_flutter/ui/app/presenters/entity_presenter.dart';
|
|
import 'package:invoiceninja_flutter/ui/credit/edit/credit_edit_items_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/invoice/edit/invoice_edit_contacts_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/invoice/edit/invoice_edit_details_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/invoice/edit/invoice_edit_items_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/invoice/edit/invoice_edit_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/purchase_order/edit/purchase_order_edit_items_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_items_vm.dart';
|
|
import 'package:invoiceninja_flutter/ui/recurring_invoice/edit/recurring_invoice_edit_items_vm.dart';
|
|
import 'package:invoiceninja_flutter/utils/completers.dart';
|
|
import 'package:invoiceninja_flutter/utils/formatting.dart';
|
|
import 'package:invoiceninja_flutter/utils/icons.dart';
|
|
import 'package:invoiceninja_flutter/utils/localization.dart';
|
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
|
import 'package:printing/printing.dart';
|
|
|
|
import 'package:invoiceninja_flutter/utils/web_stub.dart'
|
|
if (dart.library.html) 'package:invoiceninja_flutter/utils/web.dart';
|
|
|
|
class InvoiceEditDesktop extends StatefulWidget {
|
|
const InvoiceEditDesktop({
|
|
Key key,
|
|
@required this.viewModel,
|
|
@required this.entityViewModel,
|
|
}) : super(key: key);
|
|
|
|
final EntityEditDetailsVM viewModel;
|
|
final AbstractInvoiceEditVM entityViewModel;
|
|
|
|
@override
|
|
InvoiceEditDesktopState createState() => InvoiceEditDesktopState();
|
|
}
|
|
|
|
class InvoiceEditDesktopState extends State<InvoiceEditDesktop>
|
|
with TickerProviderStateMixin {
|
|
TabController _optionTabController;
|
|
TabController _tableTabController;
|
|
|
|
bool _showTasksTable = false;
|
|
FocusNode _focusNode;
|
|
|
|
final _invoiceNumberController = TextEditingController();
|
|
final _poNumberController = TextEditingController();
|
|
final _discountController = TextEditingController();
|
|
final _partialController = TextEditingController();
|
|
final _custom1Controller = TextEditingController();
|
|
final _custom2Controller = TextEditingController();
|
|
final _custom3Controller = TextEditingController();
|
|
final _custom4Controller = TextEditingController();
|
|
final _surcharge1Controller = TextEditingController();
|
|
final _surcharge2Controller = TextEditingController();
|
|
final _surcharge3Controller = TextEditingController();
|
|
final _surcharge4Controller = TextEditingController();
|
|
final _publicNotesController = TextEditingController();
|
|
final _privateNotesController = TextEditingController();
|
|
final _termsController = TextEditingController();
|
|
final _footerController = TextEditingController();
|
|
|
|
ScrollController _scrollController;
|
|
List<TextEditingController> _controllers = [];
|
|
final _debouncer = Debouncer();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
final invoice = widget.viewModel.invoice;
|
|
_showTasksTable = invoice.hasTasks && !invoice.hasProducts;
|
|
|
|
_focusNode = FocusScopeNode();
|
|
_optionTabController = TabController(vsync: this, length: 5);
|
|
_tableTabController = TabController(
|
|
vsync: this, length: 2, initialIndex: _showTasksTable ? 1 : 0);
|
|
_scrollController = ScrollController();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
_controllers = [
|
|
_invoiceNumberController,
|
|
_poNumberController,
|
|
_discountController,
|
|
_partialController,
|
|
_custom1Controller,
|
|
_custom2Controller,
|
|
_custom3Controller,
|
|
_custom4Controller,
|
|
_surcharge1Controller,
|
|
_surcharge2Controller,
|
|
_surcharge3Controller,
|
|
_surcharge4Controller,
|
|
_publicNotesController,
|
|
_privateNotesController,
|
|
_termsController,
|
|
_footerController,
|
|
];
|
|
|
|
_controllers
|
|
.forEach((dynamic controller) => controller.removeListener(_onChanged));
|
|
|
|
final invoice = widget.viewModel.invoice;
|
|
_invoiceNumberController.text = invoice.number;
|
|
_poNumberController.text = invoice.poNumber;
|
|
_discountController.text = formatNumber(invoice.discount, context,
|
|
formatNumberType: FormatNumberType.inputMoney);
|
|
_partialController.text = formatNumber(invoice.partial, context,
|
|
formatNumberType: FormatNumberType.inputMoney);
|
|
_custom1Controller.text = invoice.customValue1;
|
|
_custom2Controller.text = invoice.customValue2;
|
|
_custom3Controller.text = invoice.customValue3;
|
|
_custom4Controller.text = invoice.customValue4;
|
|
_surcharge1Controller.text = formatNumber(invoice.customSurcharge1, context,
|
|
formatNumberType: FormatNumberType.inputMoney);
|
|
_surcharge2Controller.text = formatNumber(invoice.customSurcharge2, context,
|
|
formatNumberType: FormatNumberType.inputMoney);
|
|
_surcharge3Controller.text = formatNumber(invoice.customSurcharge3, context,
|
|
formatNumberType: FormatNumberType.inputMoney);
|
|
_surcharge4Controller.text = formatNumber(invoice.customSurcharge4, context,
|
|
formatNumberType: FormatNumberType.inputMoney);
|
|
_publicNotesController.text = invoice.publicNotes;
|
|
_privateNotesController.text = invoice.privateNotes;
|
|
_termsController.text = invoice.terms;
|
|
_footerController.text = invoice.footer;
|
|
|
|
_controllers
|
|
.forEach((dynamic controller) => controller.addListener(_onChanged));
|
|
|
|
super.didChangeDependencies();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusNode.dispose();
|
|
_optionTabController.dispose();
|
|
_tableTabController.dispose();
|
|
_controllers.forEach((controller) {
|
|
controller.removeListener(_onChanged);
|
|
controller.dispose();
|
|
});
|
|
_scrollController.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
void _onChanged() {
|
|
final invoice = widget.viewModel.invoice.rebuild((b) => b
|
|
..number = _invoiceNumberController.text.trim()
|
|
..poNumber = _poNumberController.text.trim()
|
|
..discount = parseDouble(_discountController.text)
|
|
..partial = parseDouble(_partialController.text)
|
|
..customValue1 = _custom1Controller.text.trim()
|
|
..customValue2 = _custom2Controller.text.trim()
|
|
..customValue3 = _custom3Controller.text.trim()
|
|
..customValue4 = _custom4Controller.text.trim()
|
|
..customSurcharge1 = parseDouble(_surcharge1Controller.text)
|
|
..customSurcharge2 = parseDouble(_surcharge2Controller.text)
|
|
..customSurcharge3 = parseDouble(_surcharge3Controller.text)
|
|
..customSurcharge4 = parseDouble(_surcharge4Controller.text)
|
|
..publicNotes = _publicNotesController.text.trim()
|
|
..privateNotes = _privateNotesController.text.trim()
|
|
..terms = _termsController.text.trim()
|
|
..footer = _footerController.text.trim());
|
|
if (invoice != widget.viewModel.invoice) {
|
|
_debouncer.run(() {
|
|
widget.viewModel.onChanged(invoice);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final store = StoreProvider.of<AppState>(context);
|
|
final localization = AppLocalization.of(context);
|
|
final viewModel = widget.viewModel;
|
|
final state = viewModel.state;
|
|
final invoice = viewModel.invoice;
|
|
final company = viewModel.company;
|
|
final client = state.clientState.get(invoice.clientId);
|
|
final vendor = state.vendorState.get(invoice.vendorId);
|
|
final entityType = invoice.entityType;
|
|
final originalInvoice =
|
|
state.getEntity(invoice.entityType, invoice.id) as InvoiceEntity;
|
|
|
|
final countProducts = invoice.lineItems
|
|
.where((item) =>
|
|
!item.isEmpty && item.typeId != InvoiceItemEntity.TYPE_TASK)
|
|
.length;
|
|
final countTasks = invoice.lineItems
|
|
.where((item) =>
|
|
!item.isEmpty && item.typeId == InvoiceItemEntity.TYPE_TASK)
|
|
.length;
|
|
|
|
final settings = getClientSettings(state, client);
|
|
final terms = entityType == EntityType.quote
|
|
? settings.defaultValidUntil
|
|
: settings.defaultPaymentTerms;
|
|
String termsString;
|
|
if ((terms ?? '').isNotEmpty) {
|
|
termsString = '${localization.net} $terms';
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
controller: _scrollController,
|
|
child: Column(
|
|
key: ValueKey('__invoice_${invoice.id}__'),
|
|
children: <Widget>[
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: FormCard(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
padding: const EdgeInsets.only(
|
|
top: kMobileDialogPadding,
|
|
right: kMobileDialogPadding / 2,
|
|
bottom: kMobileDialogPadding,
|
|
left: kMobileDialogPadding),
|
|
children: <Widget>[
|
|
if (invoice.isNew)
|
|
if (invoice.isPurchaseOrder)
|
|
VendorPicker(
|
|
autofocus: true,
|
|
vendorId: invoice.vendorId,
|
|
vendorState: state.vendorState,
|
|
onSelected: (vendor) {
|
|
viewModel.onVendorChanged(context, invoice, vendor);
|
|
},
|
|
onAddPressed: (completer) =>
|
|
viewModel.onAddVendorPressed(context, completer),
|
|
)
|
|
else
|
|
ClientPicker(
|
|
autofocus: true,
|
|
clientId: invoice.clientId,
|
|
clientState: state.clientState,
|
|
onSelected: (client) {
|
|
viewModel.onClientChanged(context, invoice, client);
|
|
},
|
|
onAddPressed: (completer) =>
|
|
viewModel.onAddClientPressed(context, completer),
|
|
)
|
|
else
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minWidth: double.infinity, minHeight: 40),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(6),
|
|
child: Text(
|
|
EntityPresenter()
|
|
.initialize(
|
|
invoice.isPurchaseOrder ? vendor : client,
|
|
context)
|
|
.title(),
|
|
style: Theme.of(context).textTheme.headline6,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: 186),
|
|
child: InvoiceEditContactsScreen(
|
|
entityType: invoice.entityType,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: FormCard(
|
|
padding: const EdgeInsets.only(
|
|
top: kMobileDialogPadding,
|
|
right: kMobileDialogPadding / 2,
|
|
bottom: kMobileDialogPadding,
|
|
left: kMobileDialogPadding / 2),
|
|
children: <Widget>[
|
|
if (entityType == EntityType.recurringInvoice) ...[
|
|
AppDropdownButton<String>(
|
|
labelText: localization.frequency,
|
|
value: invoice.frequencyId,
|
|
onChanged: (dynamic value) {
|
|
viewModel.onChanged(
|
|
invoice.rebuild((b) => b..frequencyId = value));
|
|
},
|
|
items: kFrequencies.entries
|
|
.map((entry) => DropdownMenuItem(
|
|
value: entry.key,
|
|
child:
|
|
Text(localization.lookup(entry.value)),
|
|
))
|
|
.toList()),
|
|
DatePicker(
|
|
labelText: (invoice.lastSentDate ?? '').isNotEmpty
|
|
? localization.nextSendDate
|
|
: localization.startDate,
|
|
onSelected: (date, _) {
|
|
viewModel.onChanged(
|
|
invoice.rebuild((b) => b..nextSendDate = date));
|
|
},
|
|
selectedDate: invoice.nextSendDate,
|
|
firstDate: DateTime.now(),
|
|
),
|
|
AppDropdownButton<int>(
|
|
labelText: localization.remainingCycles,
|
|
value: invoice.remainingCycles,
|
|
blankValue: null,
|
|
onChanged: (dynamic value) => viewModel.onChanged(
|
|
invoice.rebuild((b) => b..remainingCycles = value)),
|
|
items: [
|
|
DropdownMenuItem(
|
|
child: Text(localization.endless),
|
|
value: -1,
|
|
),
|
|
...List<int>.generate(61, (i) => i)
|
|
.map((value) => DropdownMenuItem(
|
|
child: Text('$value'),
|
|
value: value,
|
|
))
|
|
.toList()
|
|
],
|
|
),
|
|
AppDropdownButton<String>(
|
|
labelText: localization.dueDate,
|
|
value: invoice.dueDateDays ?? '',
|
|
onChanged: (dynamic value) {
|
|
viewModel.onChanged(
|
|
invoice.rebuild((b) => b..dueDateDays = value));
|
|
},
|
|
items: [
|
|
DropdownMenuItem(
|
|
child: Text(localization.usePaymentTerms),
|
|
value: 'terms',
|
|
),
|
|
...List<int>.generate(31, (i) => i + 1)
|
|
.map((value) => DropdownMenuItem(
|
|
child: Text(value == 1
|
|
? localization.firstDayOfTheMonth
|
|
: value == 31
|
|
? localization.lastDayOfTheMonth
|
|
: localization.dayCount
|
|
.replaceFirst(
|
|
':count', '$value')),
|
|
value: '$value',
|
|
))
|
|
.toList()
|
|
],
|
|
),
|
|
] else ...[
|
|
DatePicker(
|
|
validator: (String val) => val.trim().isEmpty
|
|
? AppLocalization.of(context).pleaseSelectADate
|
|
: null,
|
|
labelText: entityType == EntityType.credit
|
|
? localization.creditDate
|
|
: entityType == EntityType.quote
|
|
? localization.quoteDate
|
|
: localization.invoiceDate,
|
|
selectedDate: invoice.date,
|
|
onSelected: (date, _) {
|
|
viewModel.onChanged(
|
|
invoice.rebuild((b) => b..date = date));
|
|
},
|
|
),
|
|
DatePicker(
|
|
key: ValueKey('__terms_${client.id}__'),
|
|
labelText: entityType == EntityType.invoice
|
|
? localization.dueDate
|
|
: localization.validUntil,
|
|
selectedDate: invoice.dueDate,
|
|
message: termsString,
|
|
onSelected: (date, _) {
|
|
viewModel.onChanged(
|
|
invoice.rebuild((b) => b..dueDate = date));
|
|
},
|
|
),
|
|
DecoratedFormField(
|
|
label: localization.partialDeposit,
|
|
controller: _partialController,
|
|
keyboardType: TextInputType.numberWithOptions(
|
|
decimal: true, signed: true),
|
|
onSavePressed: widget.entityViewModel.onSavePressed,
|
|
validator: (String value) {
|
|
final amount = parseDouble(_partialController.text);
|
|
final total = invoice.calculateTotal(
|
|
precision: precisionForInvoice(state, invoice));
|
|
if (amount < 0 || (amount != 0 && amount > total)) {
|
|
return localization.partialValue;
|
|
} else {
|
|
return null;
|
|
}
|
|
},
|
|
),
|
|
if (invoice.partial != null && invoice.partial > 0)
|
|
DatePicker(
|
|
labelText: localization.partialDueDate,
|
|
selectedDate: invoice.partialDueDate,
|
|
onSelected: (date, _) {
|
|
viewModel.onChanged(invoice
|
|
.rebuild((b) => b..partialDueDate = date));
|
|
},
|
|
),
|
|
],
|
|
CustomField(
|
|
controller: _custom1Controller,
|
|
field: CustomFieldType.invoice1,
|
|
value: invoice.customValue1,
|
|
onSavePressed: widget.entityViewModel.onSavePressed,
|
|
),
|
|
CustomField(
|
|
controller: _custom3Controller,
|
|
field: CustomFieldType.invoice3,
|
|
value: invoice.customValue3,
|
|
onSavePressed: widget.entityViewModel.onSavePressed,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: FormCard(
|
|
padding: const EdgeInsets.only(
|
|
top: kMobileDialogPadding,
|
|
right: kMobileDialogPadding,
|
|
bottom: kMobileDialogPadding,
|
|
left: kMobileDialogPadding / 2),
|
|
children: <Widget>[
|
|
DecoratedFormField(
|
|
controller: _invoiceNumberController,
|
|
label: entityType == EntityType.purchaseOrder
|
|
? localization.poNumber
|
|
: entityType == EntityType.credit
|
|
? localization.creditNumber
|
|
: entityType == EntityType.quote
|
|
? localization.quoteNumber
|
|
: localization.invoiceNumber,
|
|
validator: (String val) => val.trim().isEmpty &&
|
|
invoice.isOld &&
|
|
originalInvoice.number.isNotEmpty
|
|
? AppLocalization.of(context)
|
|
.pleaseEnterAnInvoiceNumber
|
|
: null,
|
|
keyboardType: TextInputType.text,
|
|
onSavePressed: widget.entityViewModel.onSavePressed,
|
|
),
|
|
if (!invoice.isPurchaseOrder)
|
|
DecoratedFormField(
|
|
label: localization.poNumber,
|
|
controller: _poNumberController,
|
|
onSavePressed: widget.entityViewModel.onSavePressed,
|
|
keyboardType: TextInputType.text,
|
|
),
|
|
DiscountField(
|
|
controller: _discountController,
|
|
value: invoice.discount,
|
|
isAmountDiscount: invoice.isAmountDiscount,
|
|
onTypeChanged: (value) => viewModel.onChanged(
|
|
invoice.rebuild((b) => b..isAmountDiscount = value)),
|
|
),
|
|
if (entityType == EntityType.recurringInvoice)
|
|
AppDropdownButton<String>(
|
|
labelText: localization.autoBill,
|
|
value: invoice.autoBill,
|
|
onChanged: (dynamic value) => viewModel.onChanged(
|
|
invoice.rebuild((b) => b..autoBill = value)),
|
|
items: [
|
|
SettingsEntity.AUTO_BILL_ALWAYS,
|
|
SettingsEntity.AUTO_BILL_OPT_OUT,
|
|
SettingsEntity.AUTO_BILL_OPT_IN,
|
|
SettingsEntity.AUTO_BILL_OFF,
|
|
]
|
|
.map((value) => DropdownMenuItem(
|
|
child: Text(localization.lookup(value)),
|
|
value: value,
|
|
))
|
|
.toList(),
|
|
),
|
|
CustomField(
|
|
controller: _custom2Controller,
|
|
field: CustomFieldType.invoice2,
|
|
value: invoice.customValue2,
|
|
onSavePressed: widget.entityViewModel.onSavePressed,
|
|
),
|
|
CustomField(
|
|
controller: _custom4Controller,
|
|
field: CustomFieldType.invoice4,
|
|
value: invoice.customValue4,
|
|
onSavePressed: widget.entityViewModel.onSavePressed,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (invoice.isInvoice &&
|
|
(invoice.hasTasks ||
|
|
invoice.lineItems.any((item) => item.isTask) ||
|
|
(company.showTasksTable ?? false)))
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
|
child: AppTabBar(
|
|
onTap: (index) {
|
|
setState(() => _showTasksTable = index == 1);
|
|
},
|
|
controller: _tableTabController,
|
|
tabs: [
|
|
Tab(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(getEntityIcon(EntityType.product)),
|
|
SizedBox(width: 8),
|
|
Text(localization.products +
|
|
(countProducts > 0 ? ' ($countProducts)' : '')),
|
|
],
|
|
),
|
|
),
|
|
Tab(
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(getEntityIcon(EntityType.task)),
|
|
SizedBox(width: 8),
|
|
Text(localization.tasks +
|
|
(countTasks > 0 ? ' ($countTasks)' : '')),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (entityType == EntityType.credit)
|
|
CreditEditItemsScreen(
|
|
viewModel: widget.entityViewModel,
|
|
isTasks: _showTasksTable,
|
|
)
|
|
else if (entityType == EntityType.quote)
|
|
QuoteEditItemsScreen(
|
|
viewModel: widget.entityViewModel,
|
|
)
|
|
else if (entityType == EntityType.invoice)
|
|
InvoiceEditItemsScreen(
|
|
viewModel: widget.entityViewModel,
|
|
isTasks: _showTasksTable,
|
|
)
|
|
else if (entityType == EntityType.recurringInvoice)
|
|
RecurringInvoiceEditItemsScreen(
|
|
viewModel: widget.entityViewModel,
|
|
isTasks: _showTasksTable,
|
|
)
|
|
else if (entityType == EntityType.purchaseOrder)
|
|
PurchaseOrderEditItemsScreen(
|
|
viewModel: widget.entityViewModel,
|
|
)
|
|
else
|
|
SizedBox(),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Expanded(
|
|
flex: 2,
|
|
child: FormCard(
|
|
padding: const EdgeInsets.only(
|
|
top: kMobileDialogPadding,
|
|
right: kMobileDialogPadding / 2,
|
|
bottom: kMobileDialogPadding,
|
|
left: kMobileDialogPadding),
|
|
children: <Widget>[
|
|
AppTabBar(
|
|
controller: _optionTabController,
|
|
tabs: [
|
|
Tab(text: localization.terms),
|
|
Tab(text: localization.footer),
|
|
Tab(text: localization.publicNotes),
|
|
Tab(text: localization.privateNotes),
|
|
Tab(text: localization.settings),
|
|
],
|
|
),
|
|
SizedBox(
|
|
height: 176,
|
|
child: TabBarView(
|
|
controller: _optionTabController,
|
|
children: <Widget>[
|
|
DecoratedFormField(
|
|
maxLines: 7,
|
|
controller: _termsController,
|
|
keyboardType: TextInputType.multiline,
|
|
label: entityType == EntityType.credit
|
|
? localization.creditTerms
|
|
: entityType == EntityType.quote
|
|
? localization.quoteTerms
|
|
: localization.invoiceTerms,
|
|
hint: invoice.isOld && !invoice.isRecurringInvoice
|
|
? ''
|
|
: settings.getDefaultTerms(invoice.entityType),
|
|
),
|
|
DecoratedFormField(
|
|
maxLines: 7,
|
|
controller: _footerController,
|
|
keyboardType: TextInputType.multiline,
|
|
label: entityType == EntityType.credit
|
|
? localization.creditFooter
|
|
: entityType == EntityType.quote
|
|
? localization.quoteFooter
|
|
: localization.invoiceFooter,
|
|
hint: invoice.isOld && !invoice.isRecurringInvoice
|
|
? ''
|
|
: settings.getDefaultFooter(invoice.entityType),
|
|
),
|
|
DecoratedFormField(
|
|
maxLines: 7,
|
|
controller: _publicNotesController,
|
|
keyboardType: TextInputType.multiline,
|
|
label: localization.publicNotes,
|
|
hint: client.publicNotes,
|
|
),
|
|
DecoratedFormField(
|
|
maxLines: 7,
|
|
controller: _privateNotesController,
|
|
keyboardType: TextInputType.multiline,
|
|
label: localization.privateNotes,
|
|
),
|
|
LayoutBuilder(builder: (context, constraints) {
|
|
return GridView.count(
|
|
physics: NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: kTableColumnGap,
|
|
shrinkWrap: true,
|
|
primary: true,
|
|
crossAxisCount: 2,
|
|
childAspectRatio:
|
|
((constraints.maxWidth / 2) - 8) / 50,
|
|
children: [
|
|
UserPicker(
|
|
userId: invoice.assignedUserId,
|
|
onChanged: (userId) => viewModel.onChanged(
|
|
invoice.rebuild(
|
|
(b) => b..assignedUserId = userId)),
|
|
),
|
|
if (company.isModuleEnabled(EntityType.project))
|
|
ProjectPicker(
|
|
clientId: invoice.clientId,
|
|
projectId: invoice.projectId,
|
|
onChanged: (projectId) {
|
|
final project = store.state.projectState
|
|
.get(projectId);
|
|
final client = state.clientState
|
|
.get(project.clientId);
|
|
|
|
if (project.isOld &&
|
|
project.clientId !=
|
|
invoice.clientId) {
|
|
viewModel.onClientChanged(
|
|
context,
|
|
invoice.rebuild(
|
|
(b) => b..projectId = projectId),
|
|
client,
|
|
);
|
|
} else {
|
|
viewModel.onChanged(invoice.rebuild(
|
|
(b) => b..projectId = projectId));
|
|
}
|
|
},
|
|
),
|
|
if (invoice.isPurchaseOrder)
|
|
ClientPicker(
|
|
clientId: invoice.clientId,
|
|
clientState: state.clientState,
|
|
onSelected: (client) {
|
|
viewModel.onChanged(invoice.rebuild((b) =>
|
|
b..clientId = client?.id ?? ''));
|
|
},
|
|
)
|
|
else if (company
|
|
.isModuleEnabled(EntityType.vendor))
|
|
EntityDropdown(
|
|
entityType: EntityType.vendor,
|
|
entityId: invoice.vendorId,
|
|
labelText: localization.vendor,
|
|
entityList: memoizedDropdownVendorList(
|
|
state.vendorState.map,
|
|
state.vendorState.list,
|
|
state.userState.map,
|
|
state.staticState),
|
|
onSelected: (vendor) => viewModel.onChanged(
|
|
invoice.rebuild(
|
|
(b) => b.vendorId = vendor.id),
|
|
),
|
|
onCreateNew: (completer, name) {
|
|
store.dispatch(SaveVendorRequest(
|
|
vendor: VendorEntity()
|
|
.rebuild((b) => b..name = name),
|
|
completer: completer));
|
|
},
|
|
),
|
|
DecoratedFormField(
|
|
key: ValueKey(
|
|
'__exchange_rate_${invoice.clientId}__'),
|
|
label: localization.exchangeRate,
|
|
initialValue: formatNumber(
|
|
invoice.exchangeRate, context,
|
|
formatNumberType:
|
|
FormatNumberType.inputMoney),
|
|
onChanged: (value) => viewModel.onChanged(
|
|
invoice.rebuild((b) => b
|
|
..exchangeRate = parseDouble(value))),
|
|
keyboardType: TextInputType.numberWithOptions(
|
|
decimal: true),
|
|
onSavePressed:
|
|
widget.entityViewModel.onSavePressed,
|
|
),
|
|
DesignPicker(
|
|
initialValue: invoice.designId,
|
|
onSelected: (value) {
|
|
viewModel.onChanged(invoice.rebuild(
|
|
(b) => b..designId = value.id));
|
|
},
|
|
),
|
|
if (company.hasTaxes || invoice.isInvoice)
|
|
Row(
|
|
children: [
|
|
if (company.hasTaxes)
|
|
Expanded(
|
|
child: SwitchListTile(
|
|
dense: true,
|
|
activeColor: Theme.of(context)
|
|
.colorScheme
|
|
.secondary,
|
|
title: Text(
|
|
localization.inclusiveTaxes),
|
|
value: invoice.usesInclusiveTaxes,
|
|
onChanged: (value) {
|
|
viewModel.onChanged(
|
|
invoice.rebuild((b) => b
|
|
..usesInclusiveTaxes =
|
|
value));
|
|
},
|
|
),
|
|
),
|
|
if (invoice.isInvoice)
|
|
Expanded(
|
|
child: SwitchListTile(
|
|
dense: true,
|
|
activeColor: Theme.of(context)
|
|
.colorScheme
|
|
.secondary,
|
|
title: Text(
|
|
localization.autoBillEnabled),
|
|
value: invoice.autoBillEnabled,
|
|
onChanged: (value) {
|
|
viewModel.onChanged(
|
|
invoice.rebuild((b) => b
|
|
..autoBillEnabled = value));
|
|
},
|
|
),
|
|
),
|
|
],
|
|
)
|
|
],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: Column(
|
|
children: [
|
|
FormCard(
|
|
padding: const EdgeInsets.only(
|
|
top: kMobileDialogPadding,
|
|
right: kMobileDialogPadding,
|
|
left: kMobileDialogPadding / 2),
|
|
children: <Widget>[
|
|
TextFormField(
|
|
enabled: false,
|
|
decoration: InputDecoration(
|
|
labelText: localization.subtotal,
|
|
),
|
|
textAlign: TextAlign.end,
|
|
key: ValueKey(
|
|
'__invoice_subtotal_${invoice.calculateSubtotal(precision: precisionForInvoice(state, invoice))}_${invoice.clientId}__'),
|
|
initialValue: formatNumber(
|
|
invoice.calculateSubtotal(
|
|
precision:
|
|
precisionForInvoice(state, invoice)),
|
|
context,
|
|
clientId: invoice.clientId),
|
|
),
|
|
if (invoice.isOld &&
|
|
(invoice.isInvoice || invoice.isQuote))
|
|
TextFormField(
|
|
enabled: false,
|
|
decoration: InputDecoration(
|
|
labelText: localization.paidToDate,
|
|
),
|
|
textAlign: TextAlign.end,
|
|
key: ValueKey(
|
|
'__invoice_paid_to_date_${invoice.paidToDate}_${invoice.clientId}__'),
|
|
initialValue: formatNumber(
|
|
invoice.paidToDate, context,
|
|
clientId: invoice.clientId),
|
|
),
|
|
if (company.hasCustomSurcharge)
|
|
CustomSurcharges(
|
|
surcharge1Controller: _surcharge1Controller,
|
|
surcharge2Controller: _surcharge2Controller,
|
|
surcharge3Controller: _surcharge3Controller,
|
|
surcharge4Controller: _surcharge4Controller,
|
|
),
|
|
if (company.enableFirstInvoiceTaxRate ||
|
|
invoice.taxName1.isNotEmpty)
|
|
TaxRateDropdown(
|
|
onSelected: (taxRate) {
|
|
viewModel.onChanged(invoice.applyTax(taxRate));
|
|
},
|
|
labelText: localization.tax +
|
|
(company.settings.enableInclusiveTaxes
|
|
? ' - ${localization.inclusive}'
|
|
: ''),
|
|
initialTaxName: invoice.taxName1,
|
|
initialTaxRate: invoice.taxRate1,
|
|
),
|
|
if (company.enableSecondInvoiceTaxRate ||
|
|
invoice.taxName2.isNotEmpty)
|
|
TaxRateDropdown(
|
|
onSelected: (taxRate) {
|
|
viewModel.onChanged(
|
|
invoice.applyTax(taxRate, isSecond: true));
|
|
},
|
|
labelText: localization.tax +
|
|
(company.settings.enableInclusiveTaxes
|
|
? ' - ${localization.inclusive}'
|
|
: ''),
|
|
initialTaxName: invoice.taxName2,
|
|
initialTaxRate: invoice.taxRate2,
|
|
),
|
|
if (company.enableThirdInvoiceTaxRate ||
|
|
invoice.taxName3.isNotEmpty)
|
|
TaxRateDropdown(
|
|
onSelected: (taxRate) {
|
|
final updatedInvoice =
|
|
invoice.applyTax(taxRate, isThird: true);
|
|
print(
|
|
'## UPDATED\nRate 3: ${updatedInvoice.taxName3} => ${updatedInvoice.taxRate3}');
|
|
viewModel.onChanged(
|
|
invoice.applyTax(taxRate, isThird: true));
|
|
},
|
|
labelText: localization.tax +
|
|
(company.settings.enableInclusiveTaxes
|
|
? ' - ${localization.inclusive}'
|
|
: ''),
|
|
initialTaxName: invoice.taxName3,
|
|
initialTaxRate: invoice.taxRate3,
|
|
),
|
|
if (company.hasCustomSurcharge)
|
|
CustomSurcharges(
|
|
surcharge1Controller: _surcharge1Controller,
|
|
surcharge2Controller: _surcharge2Controller,
|
|
surcharge3Controller: _surcharge3Controller,
|
|
surcharge4Controller: _surcharge4Controller,
|
|
isAfterTaxes: true,
|
|
onSavePressed:
|
|
widget.entityViewModel.onSavePressed,
|
|
),
|
|
TextFormField(
|
|
enabled: false,
|
|
decoration: InputDecoration(
|
|
labelText: invoice.isQuote
|
|
? localization.total
|
|
: localization.balanceDue,
|
|
),
|
|
textAlign: TextAlign.end,
|
|
key: ValueKey(
|
|
'__invoice_total_${invoice.calculateTotal(precision: precisionForInvoice(state, invoice))}_${invoice.clientId}__'),
|
|
initialValue: formatNumber(
|
|
invoice.calculateTotal(
|
|
precision: precisionForInvoice(
|
|
state, invoice)) -
|
|
invoice.paidToDate,
|
|
context,
|
|
clientId: invoice.clientId),
|
|
),
|
|
if (invoice.partial != 0)
|
|
TextFormField(
|
|
enabled: false,
|
|
decoration: InputDecoration(
|
|
labelText: localization.partialDue,
|
|
),
|
|
textAlign: TextAlign.end,
|
|
key: ValueKey(
|
|
'__invoice_total_${invoice.partial}_${invoice.clientId}__'),
|
|
initialValue: formatNumber(
|
|
invoice.partial, context,
|
|
clientId: invoice.clientId),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (state.prefState.showPdfPreview)
|
|
Padding(
|
|
padding: const EdgeInsets.only(
|
|
left: 16, right: 16, bottom: 16, top: 2),
|
|
child: _PdfPreview(invoice: invoice),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PdfPreview extends StatefulWidget {
|
|
const _PdfPreview({Key key, @required this.invoice}) : super(key: key);
|
|
|
|
final InvoiceEntity invoice;
|
|
|
|
@override
|
|
__PdfPreviewState createState() => __PdfPreviewState();
|
|
}
|
|
|
|
class __PdfPreviewState extends State<_PdfPreview> {
|
|
final _pdfDebouncer = Debouncer(milliseconds: kMillisecondsToDebounceSave);
|
|
|
|
int _pageCount = 1;
|
|
int _currentPage = 1;
|
|
String _pdfString;
|
|
http.Response _response;
|
|
bool _isLoading = false;
|
|
bool _pendingLoad = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
|
|
loadPdf();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_PdfPreview oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
if (widget.invoice != oldWidget.invoice) {
|
|
loadPdf();
|
|
}
|
|
}
|
|
|
|
void loadPdf() async {
|
|
if (_response == null) {
|
|
_loadPdf();
|
|
} else {
|
|
_pdfDebouncer.run(() {
|
|
_loadPdf();
|
|
});
|
|
}
|
|
}
|
|
|
|
void _loadPdf() async {
|
|
final invoice = widget.invoice;
|
|
|
|
if (invoice.isPurchaseOrder) {
|
|
if (!invoice.hasVendor) {
|
|
return;
|
|
}
|
|
} else if (!invoice.hasClient) {
|
|
return;
|
|
}
|
|
|
|
if (_isLoading) {
|
|
_pendingLoad = true;
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
final store = StoreProvider.of<AppState>(context);
|
|
final state = store.state;
|
|
final credentials = state.credentials;
|
|
final webClient = WebClient();
|
|
String url = '${credentials.url}/live_preview';
|
|
|
|
if (invoice.isPurchaseOrder) {
|
|
url += '/purchase_order';
|
|
}
|
|
|
|
url += '?entity=${invoice.entityType.snakeCase}';
|
|
if (invoice.isOld) {
|
|
url += '&entity_id=${invoice.id}';
|
|
}
|
|
if (state.isStaging) {
|
|
url = url.replaceFirst('//staging.', '//preview.');
|
|
} else if (state.isHosted) {
|
|
url = url.replaceFirst('//', '//preview.');
|
|
}
|
|
|
|
final data = serializers.serializeWith(InvoiceEntity.serializer, invoice);
|
|
webClient
|
|
.post(url, credentials.token,
|
|
data: json.encode(data), rawResponse: true)
|
|
.then((dynamic response) async {
|
|
final pages = await Printing.raster(response.bodyBytes, dpi: 5).toList();
|
|
setState(() {
|
|
_isLoading = false;
|
|
_response = response;
|
|
_pageCount = pages.length;
|
|
if (_currentPage > _pageCount) {
|
|
_currentPage = _pageCount;
|
|
}
|
|
|
|
if (kIsWeb && !state.prefState.enableJSPDF) {
|
|
_pdfString =
|
|
'data:application/pdf;base64,' + base64Encode(response.bodyBytes);
|
|
WebUtils.registerWebView(_pdfString);
|
|
}
|
|
|
|
if (_pendingLoad) {
|
|
_pendingLoad = false;
|
|
_loadPdf();
|
|
}
|
|
});
|
|
}).catchError((dynamic error) {
|
|
print('## Error: $error');
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final localization = AppLocalization.of(context);
|
|
final store = StoreProvider.of<AppState>(context);
|
|
final state = store.state;
|
|
|
|
return Container(
|
|
height: 1200,
|
|
child: Stack(
|
|
alignment: Alignment.topCenter,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (_pageCount > 1 && (state.prefState.enableJSPDF || !kIsWeb))
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
AppButton(
|
|
width: 180,
|
|
label: localization.previousPage,
|
|
iconData: MdiIcons.pagePrevious,
|
|
onPressed: _currentPage == 1
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
_currentPage--;
|
|
});
|
|
}),
|
|
SizedBox(width: kTableColumnGap),
|
|
AppButton(
|
|
width: 180,
|
|
label: localization.nextPage,
|
|
iconData: MdiIcons.pageNext,
|
|
onPressed: _currentPage == _pageCount
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
_currentPage++;
|
|
});
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _response == null
|
|
? Container(
|
|
color: Colors.grey.shade300,
|
|
)
|
|
: state.prefState.enableJSPDF || !kIsWeb
|
|
? PdfPreview(
|
|
build: (format) => _response.bodyBytes,
|
|
canChangeOrientation: false,
|
|
canChangePageFormat: false,
|
|
canDebug: false,
|
|
pages: [_currentPage - 1],
|
|
maxPageWidth: 800,
|
|
)
|
|
: HtmlElementView(viewType: _pdfString),
|
|
),
|
|
],
|
|
),
|
|
if (_isLoading)
|
|
Center(
|
|
child: CircularProgressIndicator(),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|