From 2ae077713599fc285e9d92caf1c2c6e0ba31da7f Mon Sep 17 00:00:00 2001 From: Gianfranco Gasbarri Date: Wed, 23 Oct 2019 10:48:23 +0100 Subject: [PATCH] Implemented Multiselect for Invoice --- lib/main.dart | 3 +- lib/redux/invoice/invoice_actions.dart | 56 ++++++++++- lib/redux/invoice/invoice_reducer.dart | 28 ++++++ .../company_gateway_list_item.dart | 2 +- lib/ui/invoice/invoice_list.dart | 15 ++- lib/ui/invoice/invoice_list_item.dart | 99 ++++++++++++++----- lib/ui/invoice/invoice_screen.dart | 71 +++++++++++-- lib/ui/invoice/invoice_screen_vm.dart | 63 ++++++++++++ 8 files changed, 304 insertions(+), 33 deletions(-) create mode 100644 lib/ui/invoice/invoice_screen_vm.dart diff --git a/lib/main.dart b/lib/main.dart index 4145fbbe9..a8e3f1ca9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,6 +38,7 @@ import 'package:invoiceninja_flutter/ui/group/edit/group_edit_vm.dart'; import 'package:invoiceninja_flutter/ui/group/group_screen.dart'; import 'package:invoiceninja_flutter/ui/group/group_screen_vm.dart'; import 'package:invoiceninja_flutter/ui/group/view/group_view_vm.dart'; +import 'package:invoiceninja_flutter/ui/invoice/invoice_screen_vm.dart'; import 'package:invoiceninja_flutter/ui/product/product_screen_vm.dart'; import 'package:invoiceninja_flutter/ui/settings/buy_now_buttons_vm.dart'; import 'package:invoiceninja_flutter/ui/settings/client_portal_vm.dart'; @@ -306,7 +307,7 @@ class InvoiceNinjaAppState extends State { ClientScreen.route: (context) => ClientScreenBuilder(), ClientViewScreen.route: (context) => ClientViewScreen(), ClientEditScreen.route: (context) => ClientEditScreen(), - InvoiceScreen.route: (context) => InvoiceScreen(), + InvoiceScreen.route: (context) => InvoiceScreenBuilder(), InvoiceViewScreen.route: (context) => InvoiceViewScreen(), InvoiceEditScreen.route: (context) => InvoiceEditScreen(), InvoiceEmailScreen.route: (context) => InvoiceEmailScreen(), diff --git a/lib/redux/invoice/invoice_actions.dart b/lib/redux/invoice/invoice_actions.dart index 87c2ce4a0..3ff4ff09c 100644 --- a/lib/redux/invoice/invoice_actions.dart +++ b/lib/redux/invoice/invoice_actions.dart @@ -321,11 +321,20 @@ class FilterInvoicesByCustom2 implements PersistUI { void handleInvoiceAction(BuildContext context, List invoices, EntityAction action) async { + assert( + [ + EntityAction.restore, + EntityAction.archive, + EntityAction.delete, + EntityAction.toggleMultiselect + ].contains(action) || + invoices.length == 1, + 'Cannot perform this action on more than one invoice'); final store = StoreProvider.of(context); final state = store.state; final CompanyEntity company = state.selectedCompany; final localization = AppLocalization.of(context); - final invoice = invoices[0]; + final invoice = invoices.first; switch (action) { case EntityAction.edit: @@ -376,5 +385,50 @@ void handleInvoiceAction(BuildContext context, List invoices, store.dispatch(DeleteInvoiceRequest( snackBarCompleter(context, localization.deletedInvoice), invoice.id)); break; + case EntityAction.toggleMultiselect: + if (!store.state.invoiceListState.isInMultiselect()) { + store.dispatch(StartInvoiceMultiselect(context: context)); + } + + if (invoices.isEmpty) { + break; + } + + for (final invoice in invoices) { + if (!store.state.invoiceListState.isSelected(invoice)) { + store.dispatch( + AddToInvoiceMultiselect(context: context, entity: invoice)); + } else { + store.dispatch( + RemoveFromInvoiceMultiselect(context: context, entity: invoice)); + } + } + break; } } + +class StartInvoiceMultiselect { + StartInvoiceMultiselect({@required this.context}); + + final BuildContext context; +} + +class AddToInvoiceMultiselect { + AddToInvoiceMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class RemoveFromInvoiceMultiselect { + RemoveFromInvoiceMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class ClearInvoiceMultiselect { + ClearInvoiceMultiselect({@required this.context}); + + final BuildContext context; +} diff --git a/lib/redux/invoice/invoice_reducer.dart b/lib/redux/invoice/invoice_reducer.dart index 58a7f6912..a3550efbe 100644 --- a/lib/redux/invoice/invoice_reducer.dart +++ b/lib/redux/invoice/invoice_reducer.dart @@ -1,3 +1,4 @@ +import 'package:invoiceninja_flutter/data/models/entities.dart'; import 'package:invoiceninja_flutter/data/models/invoice_model.dart'; import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; import 'package:invoiceninja_flutter/redux/company/company_actions.dart'; @@ -112,6 +113,11 @@ final invoiceListReducer = combineReducers([ TypedReducer(_filterInvoices), TypedReducer(_filterInvoicesByCustom1), TypedReducer(_filterInvoicesByCustom2), + TypedReducer(_startListMultiselect), + TypedReducer(_addToListMultiselect), + TypedReducer( + _removeFromListMultiselect), + TypedReducer(_clearListMultiselect), ]); ListUIState _filterInvoicesByCustom1( @@ -176,6 +182,28 @@ ListUIState _sortInvoices(ListUIState invoiceListState, SortInvoices action) { ..sortField = action.field); } +ListUIState _startListMultiselect( + ListUIState invoiceListState, StartInvoiceMultiselect action) { + return invoiceListState.rebuild((b) => b..selectedEntities = []); +} + +ListUIState _addToListMultiselect( + ListUIState invoiceListState, AddToInvoiceMultiselect action) { + return invoiceListState + .rebuild((b) => b..selectedEntities.add(action.entity)); +} + +ListUIState _removeFromListMultiselect( + ListUIState invoiceListState, RemoveFromInvoiceMultiselect action) { + return invoiceListState + .rebuild((b) => b..selectedEntities.remove(action.entity)); +} + +ListUIState _clearListMultiselect( + ListUIState invoiceListState, ClearInvoiceMultiselect action) { + return invoiceListState.rebuild((b) => b..selectedEntities = null); +} + final invoicesReducer = combineReducers([ TypedReducer(_updateInvoice), TypedReducer(_addInvoice), diff --git a/lib/ui/company_gateway/company_gateway_list_item.dart b/lib/ui/company_gateway/company_gateway_list_item.dart index 26fdc26c0..6856506f9 100644 --- a/lib/ui/company_gateway/company_gateway_list_item.dart +++ b/lib/ui/company_gateway/company_gateway_list_item.dart @@ -49,7 +49,7 @@ class _CompanyGatewayListItemState extends State ? widget.companyGateway.matchesFilterValue(widget.filter) : null; final subtitle = filterMatch; - final listUIState = uiState.companyGatewayUIState.listUIState; + final listUIState = state.uiState.companyGatewayUIState.listUIState; final isInMultiselect = listUIState.isInMultiselect(); final showCheckbox = widget.onCheckboxChanged != null || isInMultiselect; diff --git a/lib/ui/invoice/invoice_list.dart b/lib/ui/invoice/invoice_list.dart index a02137d2b..7a35da58f 100644 --- a/lib/ui/invoice/invoice_list.dart +++ b/lib/ui/invoice/invoice_list.dart @@ -26,6 +26,7 @@ class InvoiceList extends StatelessWidget { final filteredClientId = listState.filterEntityId; final filteredClient = filteredClientId != null ? viewModel.clientMap[filteredClientId] : null; + final isInMultiselect = listState.isInMultiselect(); final documentMap = memoizedEntityDocumentMap( EntityType.invoice, state.documentState.map, state.expenseState.map); @@ -104,7 +105,19 @@ class InvoiceList extends StatelessWidget { context, [invoice], action); } }, - onLongPress: () => showDialog(), + onLongPress: () async { + final longPressIsSelection = + state.uiState.longPressSelectionIsDefault ?? + true; + if (longPressIsSelection && !isInMultiselect) { + viewModel.onEntityAction(context, [invoice], + EntityAction.toggleMultiselect); + } else { + showDialog(); + } + }, + isChecked: isInMultiselect && + listState.isSelected(invoice), ); }, ), diff --git a/lib/ui/invoice/invoice_list_item.dart b/lib/ui/invoice/invoice_list_item.dart index bbcaf5456..0ffba5f4f 100644 --- a/lib/ui/invoice/invoice_list_item.dart +++ b/lib/ui/invoice/invoice_list_item.dart @@ -9,7 +9,7 @@ import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/ui/app/dismissible_entity.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; -class InvoiceListItem extends StatelessWidget { +class InvoiceListItem extends StatefulWidget { const InvoiceListItem({ @required this.user, @required this.onEntityAction, @@ -19,6 +19,8 @@ class InvoiceListItem extends StatelessWidget { @required this.client, @required this.filter, @required this.hasDocuments, + this.onCheckboxChanged, + this.isChecked = false, }); final UserEntity user; @@ -29,49 +31,84 @@ class InvoiceListItem extends StatelessWidget { final ClientEntity client; final String filter; final bool hasDocuments; + final Function(bool) onCheckboxChanged; + final bool isChecked; + @override + _InvoiceListItemState createState() => _InvoiceListItemState(); +} + +class _InvoiceListItemState extends State + with TickerProviderStateMixin { @override Widget build(BuildContext context) { final state = StoreProvider.of(context).state; final uiState = state.uiState; final invoiceUIState = uiState.invoiceUIState; + final listUIState = invoiceUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); + final showCheckbox = widget.onCheckboxChanged != null || isInMultiselect; final localization = AppLocalization.of(context); - final filterMatch = filter != null && filter.isNotEmpty - ? (invoice.matchesFilterValue(filter) ?? - client.matchesFilterValue(filter)) + final filterMatch = widget.filter != null && widget.filter.isNotEmpty + ? (widget.invoice.matchesFilterValue(widget.filter) ?? + widget.client.matchesFilterValue(widget.filter)) : null; - final invoiceStatusId = (invoice.quoteInvoiceId ?? '').isNotEmpty + final invoiceStatusId = (widget.invoice.quoteInvoiceId ?? '').isNotEmpty ? kInvoiceStatusApproved - : invoice.invoiceStatusId; + : widget.invoice.invoiceStatusId; + + if (isInMultiselect) { + _multiselectCheckboxAnimController.forward(); + } else { + _multiselectCheckboxAnimController.animateBack(0.0); + } return DismissibleEntity( - isSelected: invoice.id == + isSelected: widget.invoice.id == (uiState.isEditing ? invoiceUIState.editing.id : invoiceUIState.selectedId), userCompany: state.userCompany, - entity: invoice, - onEntityAction: onEntityAction, + entity: widget.invoice, + onEntityAction: widget.onEntityAction, child: ListTile( - onTap: onTap, - onLongPress: onLongPress, + onTap: isInMultiselect + ? () => widget.onEntityAction(EntityAction.toggleMultiselect) + : widget.onTap, + onLongPress: widget.onLongPress, + leading: showCheckbox + ? FadeTransition( + opacity: _multiselectCheckboxAnim, + child: IgnorePointer( + ignoring: listUIState.isInMultiselect(), + child: Checkbox( + value: widget.isChecked, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) => widget.onCheckboxChanged(value), + activeColor: Theme.of(context).accentColor, + ), + ), + ) + : null, title: Container( width: MediaQuery.of(context).size.width, child: Row( children: [ Expanded( child: Text( - client.displayName, + widget.client.displayName, style: Theme.of(context).textTheme.title, ), ), Text( formatNumber( - invoice.balance > 0 ? invoice.balance : invoice.amount, + widget.invoice.balance > 0 + ? widget.invoice.balance + : widget.invoice.amount, context, - clientId: invoice.clientId), + clientId: widget.invoice.clientId), style: Theme.of(context).textTheme.title), ], ), @@ -83,14 +120,14 @@ class InvoiceListItem extends StatelessWidget { children: [ Expanded( child: filterMatch == null - ? Text((invoice.invoiceNumber + + ? Text((widget.invoice.invoiceNumber + ' • ' + formatDate( - invoice.dueDate.isNotEmpty - ? invoice.dueDate - : invoice.invoiceDate, + widget.invoice.dueDate.isNotEmpty + ? widget.invoice.dueDate + : widget.invoice.invoiceDate, context) + - (hasDocuments ? ' 📎' : '')) + (widget.hasDocuments ? ' 📎' : '')) .trim()) : Text( filterMatch, @@ -99,21 +136,39 @@ class InvoiceListItem extends StatelessWidget { ), ), Text( - invoice.isPastDue + widget.invoice.isPastDue ? localization.pastDue : localization .lookup('invoice_status_$invoiceStatusId'), style: TextStyle( - color: invoice.isPastDue + color: widget.invoice.isPastDue ? Colors.red : InvoiceStatusColors.colors[invoiceStatusId], )), ], ), - EntityStateLabel(invoice), + EntityStateLabel(widget.invoice), ], ), ), ); } + + Animation _multiselectCheckboxAnim; + AnimationController _multiselectCheckboxAnimController; + + @override + void initState() { + super.initState(); + _multiselectCheckboxAnimController = + AnimationController(vsync: this, duration: Duration(milliseconds: 500)); + _multiselectCheckboxAnim = Tween(begin: 0.0, end: 1.0) + .animate(_multiselectCheckboxAnimController); + } + + @override + void dispose() { + _multiselectCheckboxAnimController.dispose(); + super.dispose(); + } } diff --git a/lib/ui/invoice/invoice_screen.dart b/lib/ui/invoice/invoice_screen.dart index 5611bc3a0..660a26b4f 100644 --- a/lib/ui/invoice/invoice_screen.dart +++ b/lib/ui/invoice/invoice_screen.dart @@ -1,5 +1,6 @@ import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/ui/app/app_scaffold.dart'; +import 'package:invoiceninja_flutter/ui/app/entities/entity_actions_dialog.dart'; import 'package:invoiceninja_flutter/ui/app/list_filter.dart'; import 'package:invoiceninja_flutter/ui/app/list_filter_button.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; @@ -11,17 +12,41 @@ import 'package:flutter_redux/flutter_redux.dart'; import 'package:invoiceninja_flutter/redux/invoice/invoice_actions.dart'; import 'package:invoiceninja_flutter/ui/app/app_bottom_bar.dart'; +import 'invoice_screen_vm.dart'; + class InvoiceScreen extends StatelessWidget { + const InvoiceScreen({ + Key key, + @required this.viewModel, + }) : super(key: key); + static const String route = '/invoice'; + final InvoiceScreenVM viewModel; + @override Widget build(BuildContext context) { final store = StoreProvider.of(context); - final company = store.state.selectedCompany; + final state = store.state; + final company = state.selectedCompany; final userCompany = store.state.userCompany; final localization = AppLocalization.of(context); + final listUIState = store.state.uiState.invoiceUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); return AppScaffold( + isChecked: isInMultiselect && + listUIState.selectedEntities.length == viewModel.invoiceList.length, + showCheckbox: isInMultiselect, + onCheckboxChanged: (value) { + final invoices = viewModel.invoiceList + .map((invoiceId) => viewModel.invoiceMap[invoiceId]) + .where((invoice) => value != listUIState.isSelected(invoice)) + .toList(); + + viewModel.onEntityAction( + context, invoices, EntityAction.toggleMultiselect); + }, appBarTitle: ListFilter( key: ValueKey(store.state.invoiceListState.filterClearedAt), entityType: EntityType.invoice, @@ -30,12 +55,44 @@ class InvoiceScreen extends StatelessWidget { }, ), appBarActions: [ - ListFilterButton( - entityType: EntityType.invoice, - onFilterPressed: (String value) { - store.dispatch(FilterInvoices(value)); - }, - ), + if (!viewModel.isInMultiselect) + ListFilterButton( + entityType: EntityType.invoice, + onFilterPressed: (String value) { + store.dispatch(FilterInvoices(value)); + }, + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + child: Text( + localization.cancel, + style: TextStyle(color: Colors.white), + ), + onPressed: () { + store.dispatch(ClearInvoiceMultiselect(context: context)); + }, + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + textColor: Colors.white, + disabledTextColor: Colors.white54, + child: Text( + localization.done, + ), + onPressed: state.invoiceListState.selectedEntities.isEmpty + ? null + : () async { + await showEntityActionsDialog( + entities: state.invoiceListState.selectedEntities, + userCompany: userCompany, + context: context, + onEntityAction: viewModel.onEntityAction, + multiselect: true); + store.dispatch(ClearInvoiceMultiselect(context: context)); + }, + ), ], body: InvoiceListBuilder(), bottomNavigationBar: AppBottomBar( diff --git a/lib/ui/invoice/invoice_screen_vm.dart b/lib/ui/invoice/invoice_screen_vm.dart new file mode 100644 index 000000000..23e5b4f99 --- /dev/null +++ b/lib/ui/invoice/invoice_screen_vm.dart @@ -0,0 +1,63 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/redux/invoice/invoice_actions.dart'; +import 'package:invoiceninja_flutter/redux/invoice/invoice_selectors.dart'; +import 'package:redux/redux.dart'; + +import 'invoice_screen.dart'; + +class InvoiceScreenBuilder extends StatelessWidget { + const InvoiceScreenBuilder({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + //rebuildOnChange: true, + converter: InvoiceScreenVM.fromStore, + builder: (context, vm) { + return InvoiceScreen( + viewModel: vm, + ); + }, + ); + } +} + +class InvoiceScreenVM { + InvoiceScreenVM({ + @required this.isInMultiselect, + @required this.invoiceList, + @required this.userCompany, + @required this.onEntityAction, + @required this.invoiceMap, + }); + + final bool isInMultiselect; + final UserCompanyEntity userCompany; + final List invoiceList; + final Function(BuildContext, List, EntityAction) onEntityAction; + final BuiltMap invoiceMap; + + static InvoiceScreenVM fromStore(Store store) { + final state = store.state; + + return InvoiceScreenVM( + invoiceMap: state.invoiceState.map, + invoiceList: memoizedFilteredInvoiceList( + state.invoiceState.map, + state.invoiceState.list, + state.clientState.map, + state.invoiceListState), + userCompany: state.userCompany, + isInMultiselect: state.invoiceListState.isInMultiselect(), + onEntityAction: (BuildContext context, List invoices, + EntityAction action) => + handleInvoiceAction(context, invoices, action), + ); + } +}