From b4b2afaa4ff588276b6705b937e79b414bd3fd0c Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 23 Aug 2018 22:47:49 -0700 Subject: [PATCH] Quotes --- lib/redux/quote/quote_middleware.dart | 139 +++++--- lib/ui/quote/edit/quote_edit.dart | 8 +- lib/ui/quote/edit/quote_edit_details.dart | 285 +++++++++++++++++ lib/ui/quote/edit/quote_edit_details_vm.dart | 75 +++++ lib/ui/quote/edit/quote_edit_items.dart | 314 +++++++++++++++++++ lib/ui/quote/edit/quote_edit_items_vm.dart | 61 ++++ lib/ui/quote/edit/quote_edit_vm.dart | 16 +- lib/ui/quote/quote_screen.dart | 2 +- stubs/redux/stub/stub_middleware | 4 - 9 files changed, 850 insertions(+), 54 deletions(-) create mode 100644 lib/ui/quote/edit/quote_edit_details.dart create mode 100644 lib/ui/quote/edit/quote_edit_details_vm.dart create mode 100644 lib/ui/quote/edit/quote_edit_items.dart create mode 100644 lib/ui/quote/edit/quote_edit_items_vm.dart diff --git a/lib/redux/quote/quote_middleware.dart b/lib/redux/quote/quote_middleware.dart index 86f59b282..cd483dc27 100644 --- a/lib/redux/quote/quote_middleware.dart +++ b/lib/redux/quote/quote_middleware.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:invoiceninja_flutter/redux/dashboard/dashboard_actions.dart'; -import 'package:redux/redux.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; -import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; -import 'package:invoiceninja_flutter/ui/quote/quote_screen.dart'; -import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_vm.dart'; -import 'package:invoiceninja_flutter/ui/quote/view/quote_view_vm.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_actions.dart'; +import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; +import 'package:invoiceninja_flutter/ui/app/invoice/invoice_email_vm.dart'; +import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/quote/quote_screen.dart'; +import 'package:invoiceninja_flutter/ui/quote/view/quote_view_vm.dart'; +import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/redux/client/client_actions.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/data/repositories/quote_repository.dart'; @@ -17,49 +17,38 @@ List> createStoreQuotesMiddleware([ final viewQuoteList = _viewQuoteList(); final viewQuote = _viewQuote(); final editQuote = _editQuote(); + final showEmailQuote = _showEmailQuote(); final loadQuotes = _loadQuotes(repository); final loadQuote = _loadQuote(repository); final saveQuote = _saveQuote(repository); final archiveQuote = _archiveQuote(repository); final deleteQuote = _deleteQuote(repository); final restoreQuote = _restoreQuote(repository); + final emailQuote = _emailQuote(repository); + final markSentQuote = _markSentQuote(repository); return [ TypedMiddleware(viewQuoteList), TypedMiddleware(viewQuote), TypedMiddleware(editQuote), + TypedMiddleware(showEmailQuote), TypedMiddleware(loadQuotes), TypedMiddleware(loadQuote), TypedMiddleware(saveQuote), TypedMiddleware(archiveQuote), TypedMiddleware(deleteQuote), TypedMiddleware(restoreQuote), + TypedMiddleware(emailQuote), + TypedMiddleware(markSentQuote), ]; } -Middleware _editQuote() { - return (Store store, dynamic action, NextDispatcher next) async { - next(action); - - if (action.trackRoute) { - store.dispatch(UpdateCurrentRoute(QuoteEditScreen.route)); - } - - final quote = - await Navigator.of(action.context).pushNamed(QuoteEditScreen.route); - - if (action.completer != null && quote != null) { - action.completer.complete(quote); - } - }; -} - Middleware _viewQuote() { return (Store store, dynamic action, NextDispatcher next) async { next(action); store.dispatch(UpdateCurrentRoute(QuoteViewScreen.route)); - Navigator.of(action.context).pushNamed(QuoteViewScreen.route); + await Navigator.of(action.context).pushNamed(QuoteViewScreen.route); }; } @@ -68,8 +57,34 @@ Middleware _viewQuoteList() { next(action); store.dispatch(UpdateCurrentRoute(QuoteScreen.route)); + Navigator.of(action.context).pushNamedAndRemoveUntil( + QuoteScreen.route, (Route route) => false); + }; +} - Navigator.of(action.context).pushNamedAndRemoveUntil(QuoteScreen.route, (Route route) => false); +Middleware _editQuote() { + return (Store store, dynamic action, NextDispatcher next) async { + next(action); + + store.dispatch(UpdateCurrentRoute(QuoteEditScreen.route)); + final quote = + await Navigator.of(action.context).pushNamed(QuoteEditScreen.route); + + if (action.completer != null && quote != null) { + action.completer.complete(quote); + } + }; +} + +Middleware _showEmailQuote() { + return (Store store, dynamic action, NextDispatcher next) async { + next(action); + + final emailWasSent = await Navigator.of(action.context).pushNamed(InvoiceEmailScreen.route); + + if (action.completer != null && emailWasSent) { + action.completer.complete(null); + } }; } @@ -78,7 +93,7 @@ Middleware _archiveQuote(QuoteRepository repository) { final origQuote = store.state.quoteState.map[action.quoteId]; repository .saveData(store.state.selectedCompany, store.state.authState, - origQuote, EntityAction.archive) + origQuote, EntityAction.archive) .then((dynamic quote) { store.dispatch(ArchiveQuoteSuccess(quote)); if (action.completer != null) { @@ -101,9 +116,10 @@ Middleware _deleteQuote(QuoteRepository repository) { final origQuote = store.state.quoteState.map[action.quoteId]; repository .saveData(store.state.selectedCompany, store.state.authState, - origQuote, EntityAction.delete) - .then((dynamic quote) { + origQuote, EntityAction.delete) + .then((InvoiceEntity quote) { store.dispatch(DeleteQuoteSuccess(quote)); + store.dispatch(LoadClient(clientId: quote.clientId)); if (action.completer != null) { action.completer.complete(null); } @@ -124,9 +140,10 @@ Middleware _restoreQuote(QuoteRepository repository) { final origQuote = store.state.quoteState.map[action.quoteId]; repository .saveData(store.state.selectedCompany, store.state.authState, - origQuote, EntityAction.restore) - .then((dynamic quote) { + origQuote, EntityAction.restore) + .then((InvoiceEntity quote) { store.dispatch(RestoreQuoteSuccess(quote)); + store.dispatch(LoadClient(clientId: quote.clientId)); if (action.completer != null) { action.completer.complete(null); } @@ -142,11 +159,60 @@ Middleware _restoreQuote(QuoteRepository repository) { }; } +Middleware _markSentQuote(QuoteRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origQuote = store.state.quoteState.map[action.quoteId]; + repository + .saveData(store.state.selectedCompany, store.state.authState, + origQuote, EntityAction.markSent) + .then((dynamic quote) { + store.dispatch(MarkSentQuoteSuccess(quote)); + store.dispatch(LoadClient(clientId: quote.clientId)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(MarkSentQuoteFailure(origQuote)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _emailQuote(QuoteRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origQuote = store.state.quoteState.map[action.quoteId]; + /* + repository + .emailQuote(store.state.selectedCompany, store.state.authState, + origQuote, action.template, action.subject, action.body) + .then((void _) { + store.dispatch(EmailQuoteSuccess()); + store.dispatch(LoadClient(clientId: origQuote.clientId)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(EmailQuoteFailure(error)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + */ + next(action); + }; +} + Middleware _saveQuote(QuoteRepository repository) { return (Store store, dynamic action, NextDispatcher next) { repository .saveData( - store.state.selectedCompany, store.state.authState, action.quote) + store.state.selectedCompany, store.state.authState, action.quote) .then((dynamic quote) { if (action.quote.isNew) { store.dispatch(AddQuoteSuccess(quote)); @@ -180,7 +246,7 @@ Middleware _loadQuote(QuoteRepository repository) { store.dispatch(LoadQuoteSuccess(quote)); if (action.completer != null) { - action.completer.complete(null); + action.completer.complete(quote); } }).catchError((Object error) { print(error); @@ -209,19 +275,18 @@ Middleware _loadQuotes(QuoteRepository repository) { } final int updatedAt = - action.force ? 0 : (state.quoteState.lastUpdated / 1000).round(); + action.force ? 0 : (state.quoteState.lastUpdated / 1000).round(); store.dispatch(LoadQuotesRequest()); repository .loadList(state.selectedCompany, state.authState, updatedAt) .then((data) { store.dispatch(LoadQuotesSuccess(data)); - if (action.completer != null) { action.completer.complete(null); } - if (state.dashboardState.isStale) { - store.dispatch(LoadDashboard()); + if (state.quoteState.isStale) { + store.dispatch(LoadQuotes()); } }).catchError((Object error) { print(error); diff --git a/lib/ui/quote/edit/quote_edit.dart b/lib/ui/quote/edit/quote_edit.dart index 077f93181..33a0aaab5 100644 --- a/lib/ui/quote/edit/quote_edit.dart +++ b/lib/ui/quote/edit/quote_edit.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/quote/edit/quote_edit_details_vm.dart'; +import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_items_vm.dart'; import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_vm.dart'; import 'package:invoiceninja_flutter/ui/invoice/edit/invoice_item_selector.dart'; import 'package:invoiceninja_flutter/utils/formatting.dart'; @@ -97,8 +97,8 @@ class _QuoteEditState extends State child: TabBarView( controller: _controller, children: [ - InvoiceEditDetailsScreen(), - InvoiceEditItemsScreen(), + QuoteEditDetailsScreen(), + QuoteEditItemsScreen(), ], ), ), diff --git a/lib/ui/quote/edit/quote_edit_details.dart b/lib/ui/quote/edit/quote_edit_details.dart new file mode 100644 index 000000000..a6c984037 --- /dev/null +++ b/lib/ui/quote/edit/quote_edit_details.dart @@ -0,0 +1,285 @@ +import 'package:invoiceninja_flutter/redux/client/client_selectors.dart'; +import 'package:invoiceninja_flutter/ui/app/forms/custom_field.dart'; +import 'package:invoiceninja_flutter/ui/app/invoice/tax_rate_dropdown.dart'; +import 'package:invoiceninja_flutter/utils/formatting.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/data/models/entities.dart'; +import 'package:invoiceninja_flutter/ui/app/entity_dropdown.dart'; +import 'package:invoiceninja_flutter/ui/app/form_card.dart'; +import 'package:invoiceninja_flutter/ui/app/forms/date_picker.dart'; +import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_details_vm.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; + +class QuoteEditDetails extends StatefulWidget { + const QuoteEditDetails({ + Key key, + @required this.viewModel, + }) : super(key: key); + + final QuoteEditDetailsVM viewModel; + + @override + QuoteEditDetailsState createState() => QuoteEditDetailsState(); +} + +class QuoteEditDetailsState extends State { + final _quoteNumberController = TextEditingController(); + final _quoteDateController = TextEditingController(); + final _poNumberController = TextEditingController(); + final _discountController = TextEditingController(); + final _partialController = TextEditingController(); + final _custom1Controller = TextEditingController(); + final _custom2Controller = TextEditingController(); + final _surcharge1Controller = TextEditingController(); + final _surcharge2Controller = TextEditingController(); + + List _controllers = []; + + @override + void didChangeDependencies() { + _controllers = [ + _quoteNumberController, + _quoteDateController, + _poNumberController, + _discountController, + _partialController, + _custom1Controller, + _custom2Controller, + _surcharge1Controller, + _surcharge2Controller, + ]; + + _controllers + .forEach((dynamic controller) => controller.removeListener(_onChanged)); + + final quote = widget.viewModel.quote; + _quoteNumberController.text = quote.invoiceNumber; + _quoteDateController.text = quote.invoiceDate; + _poNumberController.text = quote.poNumber; + _discountController.text = formatNumber(quote.discount, context, + formatNumberType: FormatNumberType.input); + _partialController.text = formatNumber(quote.partial, context, + formatNumberType: FormatNumberType.input); + _custom1Controller.text = quote.customTextValue1; + _custom2Controller.text = quote.customTextValue2; + _surcharge1Controller.text = formatNumber(quote.customValue1, context, + formatNumberType: FormatNumberType.input); + _surcharge2Controller.text = formatNumber(quote.customValue2, context, + formatNumberType: FormatNumberType.input); + + _controllers + .forEach((dynamic controller) => controller.addListener(_onChanged)); + + super.didChangeDependencies(); + } + + @override + void dispose() { + _controllers.forEach((dynamic controller) { + controller.removeListener(_onChanged); + controller.dispose(); + }); + + super.dispose(); + } + + void _onChanged() { + final quote = widget.viewModel.quote.rebuild((b) => b + ..invoiceNumber = widget.viewModel.quote.isNew + ? '' + : _quoteNumberController.text.trim() + ..poNumber = _poNumberController.text.trim() + ..discount = parseDouble(_discountController.text) + ..partial = parseDouble(_partialController.text) + ..customTextValue1 = _custom1Controller.text.trim() + ..customTextValue2 = _custom2Controller.text.trim() + ..customValue1 = parseDouble(_surcharge1Controller.text) + ..customValue2 = parseDouble(_surcharge2Controller.text)); + if (quote != widget.viewModel.quote) { + widget.viewModel.onChanged(quote); + } + } + + @override + Widget build(BuildContext context) { + final localization = AppLocalization.of(context); + final viewModel = widget.viewModel; + final quote = viewModel.quote; + final company = viewModel.company; + + return ListView( + children: [ + FormCard( + children: [ + quote.isNew + ? EntityDropdown( + entityType: EntityType.client, + labelText: localization.client, + initialValue: + viewModel.clientMap[quote.clientId]?.displayName, + entityMap: viewModel.clientMap, + entityList: memoizedDropdownClientList( + viewModel.clientMap, viewModel.clientList), + validator: (String val) => val.trim().isEmpty + ? AppLocalization.of(context).pleaseSelectAClient + : null, + onSelected: (clientId) { + viewModel.onChanged( + quote.rebuild((b) => b..clientId = clientId)); + }, + onAddPressed: (completer) { + viewModel.onAddClientPressed(context, completer); + }, + ) + : TextFormField( + autocorrect: false, + controller: _quoteNumberController, + decoration: InputDecoration( + labelText: localization.quoteNumber, + ), + validator: (String val) => val.trim().isEmpty + ? AppLocalization.of(context).pleaseEnterAQuoteNumber + : null, + ), + DatePicker( + validator: (String val) => val.trim().isEmpty + ? AppLocalization.of(context).pleaseSelectADate + : null, + labelText: localization.quoteDate, + selectedDate: quote.invoiceDate, + onSelected: (date) { + viewModel + .onChanged(quote.rebuild((b) => b..invoiceDate = date)); + }, + ), + DatePicker( + labelText: localization.dueDate, + selectedDate: quote.dueDate, + onSelected: (date) { + viewModel.onChanged(quote.rebuild((b) => b..dueDate = date)); + }, + ), + TextFormField( + controller: _partialController, + decoration: InputDecoration( + labelText: localization.partialDeposit, + ), + keyboardType: TextInputType.number, + ), + quote.partial != null && quote.partial > 0 + ? DatePicker( + labelText: localization.partialDueDate, + selectedDate: quote.partialDueDate, + onSelected: (date) { + viewModel.onChanged( + quote.rebuild((b) => b..partialDueDate = date)); + }, + ) + : Container(), + TextFormField( + autocorrect: false, + controller: _poNumberController, + decoration: InputDecoration( + labelText: localization.poNumber, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: TextFormField( + controller: _discountController, + decoration: InputDecoration( + labelText: localization.discount, + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox( + width: 10.0, + ), + DropdownButtonHideUnderline( + child: DropdownButton( + value: quote.isAmountDiscount, + items: [ + DropdownMenuItem( + child: Text( + localization.percent, + style: TextStyle( + color: Colors.grey[600], + ), + ), + value: false, + ), + DropdownMenuItem( + child: Text( + localization.amount, + style: TextStyle( + color: Colors.grey[600], + ), + ), + value: true, + ) + ], + onChanged: (bool value) => viewModel.onChanged( + quote.rebuild((b) => b..isAmountDiscount = value)), + ), + ) + ], + ), + CustomField( + controller: _custom1Controller, + labelText: company.getCustomFieldLabel(CustomFieldType.invoice1), + options: company.getCustomFieldValues(CustomFieldType.invoice1), + ), + CustomField( + controller: _custom2Controller, + labelText: company.getCustomFieldLabel(CustomFieldType.invoice2), + options: company.getCustomFieldValues(CustomFieldType.invoice2), + ), + company.getCustomFieldLabel(CustomFieldType.surcharge1).isNotEmpty + ? TextFormField( + controller: _surcharge1Controller, + decoration: InputDecoration( + labelText: company + .getCustomFieldLabel(CustomFieldType.surcharge1), + ), + keyboardType: TextInputType.number, + ) + : Container(), + company.getCustomFieldLabel(CustomFieldType.surcharge2).isNotEmpty + ? TextFormField( + controller: _surcharge2Controller, + decoration: InputDecoration( + labelText: company + .getCustomFieldLabel(CustomFieldType.surcharge2), + ), + keyboardType: TextInputType.number, + ) + : Container(), + company.enableInvoiceTaxes + ? TaxRateDropdown( + taxRates: company.taxRates, + onSelected: (taxRate) => + viewModel.onChanged(quote.applyTax(taxRate)), + labelText: localization.tax, + initialTaxName: quote.taxName1, + initialTaxRate: quote.taxRate1, + ) + : Container(), + company.enableInvoiceTaxes && company.enableSecondTaxRate + ? TaxRateDropdown( + taxRates: company.taxRates, + onSelected: (taxRate) => + viewModel.onChanged(quote.applyTax(taxRate)), + labelText: localization.tax, + initialTaxName: quote.taxName2, + initialTaxRate: quote.taxRate2, + ) + : Container(), + ], + ), + ], + ); + } +} diff --git a/lib/ui/quote/edit/quote_edit_details_vm.dart b/lib/ui/quote/edit/quote_edit_details_vm.dart new file mode 100644 index 000000000..f4cf31f98 --- /dev/null +++ b/lib/ui/quote/edit/quote_edit_details_vm.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/redux/client/client_actions.dart'; +import 'package:invoiceninja_flutter/ui/app/snackbar_row.dart'; +import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_details.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/redux/quote/quote_actions.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; + +class QuoteEditDetailsScreen extends StatelessWidget { + const QuoteEditDetailsScreen({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (Store store) { + return QuoteEditDetailsVM.fromStore(store); + }, + builder: (context, vm) { + return QuoteEditDetails( + viewModel: vm, + ); + }, + ); + } +} + +class QuoteEditDetailsVM { + final CompanyEntity company; + final InvoiceEntity quote; + final Function(InvoiceEntity) onChanged; + final BuiltMap clientMap; + final BuiltList clientList; + final Function(BuildContext context, Completer completer) onAddClientPressed; + + QuoteEditDetailsVM({ + @required this.company, + @required this.quote, + @required this.onChanged, + @required this.clientMap, + @required this.clientList, + @required this.onAddClientPressed, + }); + + factory QuoteEditDetailsVM.fromStore(Store store) { + final AppState state = store.state; + final quote = state.quoteUIState.editing; + + return QuoteEditDetailsVM( + company: state.selectedCompany, + quote: quote, + onChanged: (InvoiceEntity quote) => + store.dispatch(UpdateQuote(quote)), + clientMap: state.clientState.map, + clientList: state.clientState.list, + onAddClientPressed: (context, completer) { + store.dispatch( + EditClient(client: ClientEntity(), context: context, completer: completer, trackRoute: false)); + completer.future.then((SelectableEntity client) { + Scaffold.of(context).showSnackBar(SnackBar( + content: SnackBarRow( + message: AppLocalization.of(context).createdClient, + ) + )); + }); + }, + ); + } +} \ No newline at end of file diff --git a/lib/ui/quote/edit/quote_edit_items.dart b/lib/ui/quote/edit/quote_edit_items.dart new file mode 100644 index 000000000..bdbef8709 --- /dev/null +++ b/lib/ui/quote/edit/quote_edit_items.dart @@ -0,0 +1,314 @@ +import 'package:invoiceninja_flutter/ui/app/buttons/elevated_button.dart'; +import 'package:invoiceninja_flutter/ui/app/forms/custom_field.dart'; +import 'package:invoiceninja_flutter/ui/app/invoice/invoice_item_view.dart'; +import 'package:invoiceninja_flutter/ui/app/invoice/tax_rate_dropdown.dart'; +import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_items_vm.dart'; +import 'package:invoiceninja_flutter/utils/formatting.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:invoiceninja_flutter/ui/app/form_card.dart'; + +class QuoteEditItems extends StatefulWidget { + const QuoteEditItems({ + Key key, + @required this.viewModel, + }) : super(key: key); + + final QuoteEditItemsVM viewModel; + + @override + _QuoteEditItemsState createState() => _QuoteEditItemsState(); +} + +class _QuoteEditItemsState extends State { + InvoiceItemEntity selectedQuoteItem; + + void _showQuoteItemEditor( + InvoiceItemEntity quoteItem, BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + final viewModel = widget.viewModel; + final quote = viewModel.quote; + + return ItemEditDetails( + viewModel: viewModel, + key: Key(quoteItem.entityKey), + quoteItem: quoteItem, + index: quote.invoiceItems.indexOf( + quote.invoiceItems.firstWhere((i) => i.id == quoteItem.id)), + ); + }); + } + + @override + Widget build(BuildContext context) { + final localization = AppLocalization.of(context); + final viewModel = widget.viewModel; + final quote = viewModel.quote; + final quoteItem = quote.invoiceItems.contains(viewModel.quoteItem) + ? viewModel.quoteItem + : null; + + if (quoteItem != null && quoteItem != selectedQuoteItem) { + selectedQuoteItem = quoteItem; + WidgetsBinding.instance.addPostFrameCallback((duration) { + _showQuoteItemEditor(quoteItem, context); + }); + } + + if (quote.invoiceItems.isEmpty) { + return Center( + child: Text( + localization.clickPlusToAddItem, + style: TextStyle( + color: Colors.grey, + fontSize: 20.0, + ), + ), + ); + } + + final quoteItems = + quote.invoiceItems.map((quoteItem) => InvoiceItemListTile( + invoice: quote, + invoiceItem: quoteItem, + onTap: () => _showQuoteItemEditor(quoteItem, context), + )); + + return ListView( + children: quoteItems.toList(), + ); + } +} + +class ItemEditDetails extends StatefulWidget { + const ItemEditDetails({ + Key key, + @required this.index, + @required this.quoteItem, + @required this.viewModel, + }) : super(key: key); + + final int index; + final InvoiceItemEntity quoteItem; + final QuoteEditItemsVM viewModel; + + @override + ItemEditDetailsState createState() => ItemEditDetailsState(); +} + +class ItemEditDetailsState extends State { + final _productKeyController = TextEditingController(); + final _notesController = TextEditingController(); + final _costController = TextEditingController(); + final _qtyController = TextEditingController(); + final _discountController = TextEditingController(); + final _custom1Controller = TextEditingController(); + final _custom2Controller = TextEditingController(); + + List _controllers = []; + + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + if (_controllers.isNotEmpty) { + return; + } + + final quoteItem = widget.quoteItem; + _productKeyController.text = quoteItem.productKey; + _notesController.text = quoteItem.notes; + _costController.text = formatNumber(quoteItem.cost, context, + formatNumberType: FormatNumberType.input); + _qtyController.text = formatNumber(quoteItem.qty, context, + formatNumberType: FormatNumberType.input); + _discountController.text = formatNumber(quoteItem.discount, context, + formatNumberType: FormatNumberType.input); + _custom1Controller.text = quoteItem.customValue1; + _custom2Controller.text = quoteItem.customValue2; + + _controllers = [ + _productKeyController, + _notesController, + _costController, + _qtyController, + _discountController, + _custom1Controller, + _custom2Controller, + ]; + + _controllers + .forEach((dynamic controller) => controller.addListener(_onChanged)); + + super.didChangeDependencies(); + } + + @override + void dispose() { + _controllers.forEach((dynamic controller) { + controller.removeListener(_onChanged); + controller.dispose(); + }); + + super.dispose(); + } + + void _onChanged() { + final quoteItem = widget.quoteItem.rebuild((b) => b + ..productKey = _productKeyController.text.trim() + ..notes = _notesController.text.trim() + ..cost = parseDouble(_costController.text) + ..qty = parseDouble(_qtyController.text) + ..discount = parseDouble(_discountController.text) + ..customValue1 = _custom1Controller.text.trim() + ..customValue2 = _custom2Controller.text.trim()); + if (quoteItem != widget.quoteItem) { + widget.viewModel.onChangedQuoteItem(quoteItem, widget.index); + } + } + + @override + Widget build(BuildContext context) { + final localization = AppLocalization.of(context); + final viewModel = widget.viewModel; + final quoteItem = widget.quoteItem; + final company = viewModel.company; + + void _confirmDelete() { + showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + semanticLabel: localization.areYouSure, + title: Text(localization.areYouSure), + actions: [ + FlatButton( + child: Text(localization.cancel.toUpperCase()), + onPressed: () { + Navigator.pop(context); + }), + FlatButton( + child: Text(localization.ok.toUpperCase()), + onPressed: () { + widget.viewModel.onRemoveQuoteItemPressed(widget.index); + Navigator.pop(context); // confirmation dialog + Navigator.pop(context); // quote item editor + }) + ], + ), + ); + } + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context) + .viewInsets + .bottom, // stay clear of the keyboard + ), + child: SingleChildScrollView( + child: FormCard( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ElevatedButton( + color: Colors.red, + icon: Icons.delete, + label: localization.remove, + onPressed: _confirmDelete, + ), + SizedBox( + width: 10.0, + ), + ElevatedButton( + icon: Icons.check_circle, + label: localization.done, + onPressed: () { + viewModel.onDoneQuoteItemPressed(); + Navigator.of(context).pop(); + }, + ), + ], + ), + TextFormField( + autocorrect: false, + controller: _productKeyController, + decoration: InputDecoration( + labelText: localization.product, + ), + ), + TextFormField( + autocorrect: false, + controller: _notesController, + maxLines: 4, + decoration: InputDecoration( + labelText: localization.description, + ), + ), + CustomField( + controller: _custom1Controller, + labelText: company.getCustomFieldLabel(CustomFieldType.product1), + options: company.getCustomFieldValues(CustomFieldType.product1), + ), + CustomField( + controller: _custom2Controller, + labelText: company.getCustomFieldLabel(CustomFieldType.product2), + options: company.getCustomFieldValues(CustomFieldType.product2), + ), + TextFormField( + controller: _costController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: localization.unitCost, + ), + ), + company.hasInvoiceField('quantity') + ? TextFormField( + controller: _qtyController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: localization.quantity, + ), + ) + : Container(), + company.hasInvoiceField('discount') + ? TextFormField( + controller: _discountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: localization.discount, + ), + ) + : Container(), + company.enableInvoiceTaxes + ? TaxRateDropdown( + taxRates: company.taxRates, + onSelected: (taxRate) => viewModel.onChangedQuoteItem( + quoteItem.applyTax(taxRate), widget.index), + labelText: localization.tax, + initialTaxName: quoteItem.taxName1, + initialTaxRate: quoteItem.taxRate1, + ) + : Container(), + company.enableInvoiceTaxes && company.enableSecondTaxRate + ? TaxRateDropdown( + taxRates: company.taxRates, + onSelected: (taxRate) => viewModel.onChangedQuoteItem( + quoteItem.applyTax(taxRate), widget.index), + labelText: localization.tax, + initialTaxName: quoteItem.taxName2, + initialTaxRate: quoteItem.taxRate2, + ) + : Container(), + ], + ), + ), + ); + } +} diff --git a/lib/ui/quote/edit/quote_edit_items_vm.dart b/lib/ui/quote/edit/quote_edit_items_vm.dart new file mode 100644 index 000000000..d90bd7eff --- /dev/null +++ b/lib/ui/quote/edit/quote_edit_items_vm.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit_items.dart'; +import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/redux/quote/quote_actions.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; + +class QuoteEditItemsScreen extends StatelessWidget { + const QuoteEditItemsScreen({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (Store store) { + return QuoteEditItemsVM.fromStore(store); + }, + builder: (context, vm) { + return QuoteEditItems( + viewModel: vm, + ); + }, + ); + } +} + +class QuoteEditItemsVM { + final CompanyEntity company; + final InvoiceEntity quote; + final InvoiceItemEntity quoteItem; + final Function(int) onRemoveQuoteItemPressed; + final Function onDoneQuoteItemPressed; + final Function(InvoiceItemEntity, int) onChangedQuoteItem; + + QuoteEditItemsVM({ + @required this.company, + @required this.quote, + @required this.quoteItem, + @required this.onRemoveQuoteItemPressed, + @required this.onDoneQuoteItemPressed, + @required this.onChangedQuoteItem, + }); + + factory QuoteEditItemsVM.fromStore(Store store) { + final AppState state = store.state; + final quote = state.quoteUIState.editing; + + return QuoteEditItemsVM( + company: state.selectedCompany, + quote: quote, + quoteItem: state.quoteUIState.editingItem, + onRemoveQuoteItemPressed: (index) => + store.dispatch(DeleteQuoteItem(index)), + onDoneQuoteItemPressed: () => store.dispatch(EditQuoteItem()), + onChangedQuoteItem: (quoteItem, index) { + store.dispatch( + UpdateQuoteItem(quoteItem: quoteItem, index: index)); + }); + } +} diff --git a/lib/ui/quote/edit/quote_edit_vm.dart b/lib/ui/quote/edit/quote_edit_vm.dart index b468b41d5..6c07ca5fd 100644 --- a/lib/ui/quote/edit/quote_edit_vm.dart +++ b/lib/ui/quote/edit/quote_edit_vm.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/redux/quote/quote_actions.dart'; import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; import 'package:invoiceninja_flutter/ui/app/dialogs/error_dialog.dart'; import 'package:invoiceninja_flutter/ui/invoice/invoice_screen.dart'; import 'package:invoiceninja_flutter/ui/invoice/view/invoice_view_vm.dart'; import 'package:invoiceninja_flutter/ui/quote/edit/quote_edit.dart'; import 'package:redux/redux.dart'; -import 'package:invoiceninja_flutter/redux/invoice/invoice_actions.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; @@ -55,22 +55,22 @@ class QuoteEditVM { factory QuoteEditVM.fromStore(Store store) { final AppState state = store.state; - final invoice = state.invoiceUIState.editing; + final quote = state.quoteUIState.editing; return QuoteEditVM( company: state.selectedCompany, isSaving: state.isSaving, - quote: invoice, + quote: quote, quoteItem: state.invoiceUIState.editingItem, - origQuote: store.state.invoiceState.map[invoice.id], + origQuote: store.state.invoiceState.map[quote.id], onBackPressed: () => store.dispatch(UpdateCurrentRoute(InvoiceScreen.route)), onSavePressed: (BuildContext context) { final Completer completer = Completer(); store.dispatch( - SaveInvoiceRequest(completer: completer, invoice: invoice)); + SaveQuoteRequest(completer: completer, quote: quote)); return completer.future.then((savedInvoice) { - if (invoice.isNew) { + if (quote.isNew) { Navigator.of(context).pushReplacementNamed(InvoiceViewScreen.route); } else { Navigator.of(context).pop(savedInvoice); @@ -85,9 +85,9 @@ class QuoteEditVM { }, onItemsAdded: (items) { if (items.length == 1) { - store.dispatch(EditInvoiceItem(items[0])); + store.dispatch(EditQuoteItem(items[0])); } - store.dispatch(AddInvoiceItems(items)); + store.dispatch(AddQuoteItems(items)); }, ); } diff --git a/lib/ui/quote/quote_screen.dart b/lib/ui/quote/quote_screen.dart index 3f3add989..b96039a61 100644 --- a/lib/ui/quote/quote_screen.dart +++ b/lib/ui/quote/quote_screen.dart @@ -67,7 +67,7 @@ class QuoteScreen extends StatelessWidget { backgroundColor: Theme.of(context).primaryColorDark, onPressed: () { store.dispatch( - EditQuote(quote: InvoiceEntity(), context: context)); + EditQuote(quote: InvoiceEntity(isQuote: true), context: context)); }, child: Icon( Icons.add, diff --git a/stubs/redux/stub/stub_middleware b/stubs/redux/stub/stub_middleware index 26fb22905..aad9d64cf 100644 --- a/stubs/redux/stub/stub_middleware +++ b/stubs/redux/stub/stub_middleware @@ -41,10 +41,6 @@ Middleware _editStub() { return (Store store, dynamic action, NextDispatcher next) async { next(action); - if (action.trackRoute) { - store.dispatch(UpdateCurrentRoute(StubEditScreen.route)); - } - final stub = await Navigator.of(action.context).pushNamed(StubEditScreen.route);