diff --git a/lib/data/models/product_model.dart b/lib/data/models/product_model.dart index 92a882a0b..32d65c9ab 100644 --- a/lib/data/models/product_model.dart +++ b/lib/data/models/product_model.dart @@ -193,7 +193,7 @@ abstract class ProductEntity extends Object bool multiselect = false}) { final actions = []; - if (!isDeleted) { + if (!isDeleted && !multiselect) { if (includeEdit && userCompany.canEditEntity(this)) { actions.add(EntityAction.edit); } @@ -203,7 +203,7 @@ abstract class ProductEntity extends Object } } - if (userCompany.canCreate(EntityType.product)) { + if (userCompany.canCreate(EntityType.product) && !multiselect) { actions.add(EntityAction.clone); } diff --git a/lib/main.dart b/lib/main.dart index bcd53a158..64955754e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,15 +17,11 @@ import 'package:invoiceninja_flutter/ui/settings/products_vm.dart'; import 'package:invoiceninja_flutter/ui/settings/tax_rates_vm.dart'; import 'package:invoiceninja_flutter/ui/settings/templates_and_reminders_vm.dart'; import 'package:invoiceninja_flutter/ui/settings/user_details_vm.dart'; -import 'package:sentry/sentry.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:intl/intl.dart'; -import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; import 'package:local_auth/local_auth.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; +import 'package:redux/redux.dart'; import 'package:redux_logging/redux_logging.dart'; +import 'package:sentry/sentry.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:invoiceninja_flutter/.env.dart'; import 'package:invoiceninja_flutter/redux/settings/settings_middleware.dart'; @@ -280,7 +276,7 @@ class InvoiceNinjaAppState extends State { LoginScreen.route: (context) => LoginScreen(), MainScreen.route: (context) => MainScreen(), DashboardScreen.route: (context) => DashboardScreen(), - ProductScreen.route: (context) => ProductScreen(), + ProductScreen.route: (context) => ProductScreenBuilder(), ProductViewScreen.route: (context) => ProductViewScreen(), ProductEditScreen.route: (context) => ProductEditScreen(), ClientScreen.route: (context) => ClientScreenBuilder(), diff --git a/lib/redux/client/client_reducer.dart b/lib/redux/client/client_reducer.dart index 242419947..a72ccf487 100644 --- a/lib/redux/client/client_reducer.dart +++ b/lib/redux/client/client_reducer.dart @@ -175,7 +175,6 @@ ListUIState _removeFromListMultiselect( ListUIState _clearListMultiselect( ListUIState clientListState, ClearMultiselect action) { - // TODO: Notify UI which IDs were selected return clientListState.rebuild((b) => b..selectedEntities = null); } diff --git a/lib/redux/product/product_actions.dart b/lib/redux/product/product_actions.dart index 0b7a1c756..9451a07bd 100644 --- a/lib/redux/product/product_actions.dart +++ b/lib/redux/product/product_actions.dart @@ -196,7 +196,7 @@ class FilterProductDropdown { } void handleProductAction( - BuildContext context, List products, EntityAction action) { + BuildContext context, List products, EntityAction action) { final store = StoreProvider.of(context); final state = store.state; final localization = AppLocalization.of(context); @@ -214,7 +214,8 @@ void handleProductAction( store.dispatch(EditProduct(context: context, product: products[0])); break; case EntityAction.clone: - store.dispatch(EditProduct(context: context, product: products[0].clone)); + store.dispatch(EditProduct( + context: context, product: (products[0] as ProductEntity).clone)); break; case EntityAction.restore: store.dispatch(RestoreProductRequest( @@ -231,5 +232,50 @@ void handleProductAction( snackBarCompleter(context, localization.deletedProduct), products[0].id)); break; + case EntityAction.toggleMultiselect: + if (!store.state.productListState.isInMultiselect()) { + store.dispatch(StartMultiselect(context: context)); + } + + if (products.isEmpty) { + break; + } + + final select = !store.state.productListState.isSelected(products[0]); + for (final product in products) { + if (select) { + store.dispatch(AddToMultiselect(context: context, entity: product)); + } else { + store.dispatch( + RemoveFromMultiselect(context: context, entity: product)); + } + } + break; } } + +class StartMultiselect { + StartMultiselect({@required this.context}); + + final BuildContext context; +} + +class AddToMultiselect { + AddToMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class RemoveFromMultiselect { + RemoveFromMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class ClearMultiselect { + ClearMultiselect({@required this.context}); + + final BuildContext context; +} diff --git a/lib/redux/product/product_reducer.dart b/lib/redux/product/product_reducer.dart index d5e7707af..d38793c76 100644 --- a/lib/redux/product/product_reducer.dart +++ b/lib/redux/product/product_reducer.dart @@ -1,10 +1,11 @@ +import 'package:invoiceninja_flutter/data/models/entities.dart'; import 'package:invoiceninja_flutter/data/models/product_model.dart'; import 'package:invoiceninja_flutter/redux/company/company_actions.dart'; +import 'package:invoiceninja_flutter/redux/product/product_actions.dart'; +import 'package:invoiceninja_flutter/redux/product/product_state.dart'; import 'package:invoiceninja_flutter/redux/ui/entity_ui_state.dart'; import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart'; import 'package:redux/redux.dart'; -import 'package:invoiceninja_flutter/redux/product/product_actions.dart'; -import 'package:invoiceninja_flutter/redux/product/product_state.dart'; EntityUIState productUIReducer(ProductUIState state, dynamic action) { return state.rebuild((b) => b @@ -55,6 +56,10 @@ final productListReducer = combineReducers([ TypedReducer(_filterProductsByState), TypedReducer(_filterProductsByCustom1), TypedReducer(_filterProductsByCustom2), + TypedReducer(_startListMultiselect), + TypedReducer(_addToListMultiselect), + TypedReducer(_removeFromListMultiselect), + TypedReducer(_clearListMultiselect), ]); ListUIState _filterProductsByState( @@ -102,6 +107,28 @@ ListUIState _sortProducts(ListUIState productListState, SortProducts action) { ..sortField = action.field); } +ListUIState _startListMultiselect( + ListUIState productListState, StartMultiselect action) { + return productListState.rebuild((b) => b..selectedEntities = []); +} + +ListUIState _addToListMultiselect( + ListUIState productListState, AddToMultiselect action) { + return productListState + .rebuild((b) => b..selectedEntities.add(action.entity)); +} + +ListUIState _removeFromListMultiselect( + ListUIState productListState, RemoveFromMultiselect action) { + return productListState + .rebuild((b) => b..selectedEntities.remove(action.entity)); +} + +ListUIState _clearListMultiselect( + ListUIState productListState, ClearMultiselect action) { + return productListState.rebuild((b) => b..selectedEntities = null); +} + final productsReducer = combineReducers([ TypedReducer(_updateProduct), TypedReducer(_addProduct), diff --git a/lib/ui/app/entities/entity_actions_dialog.dart b/lib/ui/app/entities/entity_actions_dialog.dart index c080f4a76..3e5afcb70 100644 --- a/lib/ui/app/entities/entity_actions_dialog.dart +++ b/lib/ui/app/entities/entity_actions_dialog.dart @@ -4,14 +4,17 @@ import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/utils/icons.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; -Future showEntityActionsDialog({ - @required BuildContext context, - @required List entities, - @required UserCompanyEntity userCompany, - @required - Function(BuildContext, List, EntityAction) onEntityAction, - ClientEntity client, -}) async { +Future showEntityActionsDialog( + {@required + BuildContext context, + @required + List entities, + @required + UserCompanyEntity userCompany, + @required + Function(BuildContext, List, EntityAction) onEntityAction, + ClientEntity client, + bool multiselect = false}) async { if (entities == null) { return; } @@ -22,7 +25,10 @@ Future showEntityActionsDialog({ final actions = []; actions.addAll(entities[0] .getActions( - userCompany: userCompany, includeEdit: true, client: client) + userCompany: userCompany, + includeEdit: true, + client: client, + multiselect: multiselect) .map((entityAction) { if (entityAction == null) { return Divider(); diff --git a/lib/ui/client/client_list.dart b/lib/ui/client/client_list.dart index 9b074397e..cf84c14ff 100644 --- a/lib/ui/client/client_list.dart +++ b/lib/ui/client/client_list.dart @@ -64,13 +64,16 @@ class ClientList extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onChanged: (value) => - _toggleSelectionForAll(store, context), - value: - store.state.clientListState.selectedEntities.length == - viewModel.clientList.length) + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) => + _toggleSelectionForAll(store, context), + activeColor: Theme.of(context).accentColor, + value: listUIState.selectedEntities.length == + viewModel.clientList.length), + ), ], ); } diff --git a/lib/ui/client/client_list_item.dart b/lib/ui/client/client_list_item.dart index dd864d568..862a22096 100644 --- a/lib/ui/client/client_list_item.dart +++ b/lib/ui/client/client_list_item.dart @@ -1,13 +1,11 @@ -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:invoiceninja_flutter/redux/app/app_state.dart'; -import 'package:invoiceninja_flutter/redux/client/client_actions.dart'; -import 'package:invoiceninja_flutter/ui/app/entity_state_label.dart'; -import 'package:invoiceninja_flutter/utils/formatting.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/ui/app/dismissible_entity.dart'; -import 'package:redux/src/store.dart'; +import 'package:invoiceninja_flutter/ui/app/entity_state_label.dart'; +import 'package:invoiceninja_flutter/utils/formatting.dart'; class ClientListItem extends StatelessWidget { const ClientListItem({ diff --git a/lib/ui/client/client_screen.dart b/lib/ui/client/client_screen.dart index 6d1a6415d..90c55cbb8 100644 --- a/lib/ui/client/client_screen.dart +++ b/lib/ui/client/client_screen.dart @@ -113,7 +113,8 @@ class ClientScreen extends StatelessWidget { entities: store.state.clientListState.selectedEntities, userCompany: viewModel.userCompany, context: context, - onEntityAction: viewModel.onEntityAction); + onEntityAction: viewModel.onEntityAction, + multiselect: true); } store.dispatch(ClearMultiselect(context: context)); } diff --git a/lib/ui/product/product_list.dart b/lib/ui/product/product_list.dart index 494623a46..ce0fd5ef2 100644 --- a/lib/ui/product/product_list.dart +++ b/lib/ui/product/product_list.dart @@ -1,6 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/ui/app/entities/entity_actions_dialog.dart'; import 'package:invoiceninja_flutter/ui/app/help_text.dart'; import 'package:invoiceninja_flutter/ui/app/lists/list_divider.dart'; @@ -8,6 +10,7 @@ import 'package:invoiceninja_flutter/ui/app/loading_indicator.dart'; import 'package:invoiceninja_flutter/ui/product/product_list_item.dart'; import 'package:invoiceninja_flutter/ui/product/product_list_vm.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:redux/src/store.dart'; class ProductList extends StatelessWidget { const ProductList({ @@ -29,12 +32,41 @@ class ProductList extends StatelessWidget { } Widget _buildListView(BuildContext context) { + final store = StoreProvider.of(context); + final listUIState = store.state.uiState.productUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); + return RefreshIndicator( onRefresh: () => viewModel.onRefreshed(context), child: ListView.separated( separatorBuilder: (context, index) => ListDivider(), - itemCount: viewModel.productList.length, + itemCount: isInMultiselect + ? viewModel.productList.length + 1 + : viewModel.productList.length, itemBuilder: (BuildContext context, index) { + // Add header + if (index == 0 && isInMultiselect) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) => + _toggleSelectionForAll(store, context), + activeColor: Theme.of(context).accentColor, + value: listUIState.selectedEntities.length == + viewModel.productList.length), + ), + ], + ); + } + + if (isInMultiselect) { + index--; + } + final productId = viewModel.productList[index]; final product = viewModel.productMap[productId]; @@ -56,9 +88,27 @@ class ProductList extends StatelessWidget { } }, onTap: () => viewModel.onProductTap(context, product), - onLongPress: () => showDialog(), + onLongPress: () async { + final longPressIsSelection = + store.state.uiState.longPressSelectionIsDefault ?? true; + if (longPressIsSelection) { + viewModel.onEntityAction( + context, [product], EntityAction.toggleMultiselect); + } else { + showDialog(); + } + }, + isChecked: isInMultiselect && listUIState.isSelected(product), ); }), ); } + + void _toggleSelectionForAll(Store store, BuildContext context) { + final products = viewModel.productList + .map((productId) => viewModel.productMap[productId]) + .toList(); + + viewModel.onEntityAction(context, products, EntityAction.toggleMultiselect); + } } diff --git a/lib/ui/product/product_list_item.dart b/lib/ui/product/product_list_item.dart index fc307e6ed..045f7b153 100644 --- a/lib/ui/product/product_list_item.dart +++ b/lib/ui/product/product_list_item.dart @@ -1,11 +1,11 @@ -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:invoiceninja_flutter/redux/app/app_state.dart'; -import 'package:invoiceninja_flutter/ui/app/entity_state_label.dart'; -import 'package:invoiceninja_flutter/utils/formatting.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.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/ui/app/dismissible_entity.dart'; +import 'package:invoiceninja_flutter/ui/app/entity_state_label.dart'; +import 'package:invoiceninja_flutter/utils/formatting.dart'; class ProductListItem extends StatelessWidget { const ProductListItem({ @@ -28,6 +28,8 @@ class ProductListItem extends StatelessWidget { ? product.matchesFilterValue(filter) : null; final subtitle = filterMatch ?? product.notes; + final listUIState = productUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); return DismissibleEntity( isSelected: product.id == @@ -38,16 +40,22 @@ class ProductListItem extends StatelessWidget { entity: product, onEntityAction: onEntityAction, child: ListTile( - onTap: onTap, + onTap: isInMultiselect + ? () => onEntityAction(EntityAction.toggleMultiselect) + : onTap, onLongPress: onLongPress, - leading: onCheckboxChanged != null - ? Checkbox( - //key: NinjaKeys.productItemCheckbox(task.id), - value: isChecked, - onChanged: (value) => onCheckboxChanged(value), - activeColor: Theme.of(context).accentColor, - ) - : null, + leading: IgnorePointer( + ignoring: listUIState.isInMultiselect(), + child: (onCheckboxChanged != null || isInMultiselect) + ? Checkbox( + //key: NinjaKeys.productItemCheckbox(task.id), + value: isChecked, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) => onCheckboxChanged(value), + activeColor: Theme.of(context).accentColor, + ) + : null, + ), title: Container( width: MediaQuery.of(context).size.width, child: Row( diff --git a/lib/ui/product/product_list_vm.dart b/lib/ui/product/product_list_vm.dart index ed11207ff..9eb207ee8 100644 --- a/lib/ui/product/product_list_vm.dart +++ b/lib/ui/product/product_list_vm.dart @@ -67,7 +67,7 @@ class ProductListVM { onProductTap: (context, product) { store.dispatch(ViewProduct(productId: product.id, context: context)); }, - onEntityAction: (BuildContext context, List products, + onEntityAction: (BuildContext context, List products, EntityAction action) => handleProductAction(context, products, action), onRefreshed: (context) => _handleRefresh(context), diff --git a/lib/ui/product/product_screen.dart b/lib/ui/product/product_screen.dart index e3b2268bc..1fd969f85 100644 --- a/lib/ui/product/product_screen.dart +++ b/lib/ui/product/product_screen.dart @@ -1,18 +1,28 @@ -import 'package:invoiceninja_flutter/ui/app/app_scaffold.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'; -import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:flutter/material.dart'; -import 'package:invoiceninja_flutter/data/models/models.dart'; -import 'package:invoiceninja_flutter/ui/product/product_list_vm.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/product/product_actions.dart'; import 'package:invoiceninja_flutter/ui/app/app_bottom_bar.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/ui/product/product_list_vm.dart'; +import 'package:invoiceninja_flutter/ui/product/product_screen_vm.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:redux/src/store.dart'; class ProductScreen extends StatelessWidget { + const ProductScreen({ + Key key, + @required this.viewModel, + }) : super(key: key); + static const String route = '/product'; + final ProductScreenVM viewModel; + @override Widget build(BuildContext context) { final store = StoreProvider.of(context); @@ -29,12 +39,31 @@ class ProductScreen extends StatelessWidget { }, ), appBarActions: [ - ListFilterButton( - entityType: EntityType.product, - onFilterPressed: (String value) { - store.dispatch(FilterProducts(value)); - }, - ), + if (!viewModel.isInMultiselect) + ListFilterButton( + entityType: EntityType.product, + onFilterPressed: (String value) { + store.dispatch(FilterProducts(value)); + }, + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + child: Text( + localization.done, + style: TextStyle(color: Colors.white), + ), + onPressed: () => _finishMultiselect(context, 'done', store), + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + child: Text( + localization.cancel, + style: TextStyle(color: Colors.white), + ), + onPressed: () => _finishMultiselect(context, 'cancel', store), + ), ], body: ProductListBuilder(), bottomNavigationBar: AppBottomBar( @@ -74,4 +103,17 @@ class ProductScreen extends StatelessWidget { : null, ); } + + void _finishMultiselect( + BuildContext context, String mode, Store store) async { + if (mode == 'done') { + await showEntityActionsDialog( + entities: store.state.productListState.selectedEntities, + userCompany: viewModel.userCompany, + context: context, + onEntityAction: viewModel.onEntityAction, + multiselect: true); + } + store.dispatch(ClearMultiselect(context: context)); + } } diff --git a/lib/ui/product/product_screen_vm.dart b/lib/ui/product/product_screen_vm.dart new file mode 100644 index 000000000..a754cd88f --- /dev/null +++ b/lib/ui/product/product_screen_vm.dart @@ -0,0 +1,51 @@ +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/product/product_actions.dart'; +import 'package:redux/redux.dart'; + +import 'product_screen.dart'; + +class ProductScreenBuilder extends StatelessWidget { + const ProductScreenBuilder({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + //rebuildOnChange: true, + converter: ProductScreenVM.fromStore, + builder: (context, vm) { + return ProductScreen( + viewModel: vm, + ); + }, + ); + } +} + +class ProductScreenVM { + ProductScreenVM({ + @required this.isInMultiselect, + @required this.userCompany, + @required this.onEntityAction, + }); + + final bool isInMultiselect; + final UserCompanyEntity userCompany; + final Function(BuildContext, List, EntityAction) onEntityAction; + + static ProductScreenVM fromStore(Store store) { + final state = store.state; + + return ProductScreenVM( + userCompany: state.userCompany, + isInMultiselect: state.productListState.isInMultiselect(), + onEntityAction: (BuildContext context, List products, + EntityAction action) => + handleProductAction(context, products, action), + ); + } +} diff --git a/lib/ui/settings/settings_list.dart b/lib/ui/settings/settings_list.dart index c27c6b5c4..3e958b8b3 100644 --- a/lib/ui/settings/settings_list.dart +++ b/lib/ui/settings/settings_list.dart @@ -5,7 +5,6 @@ import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/data/models/entities.dart'; import 'package:invoiceninja_flutter/ui/app/lists/list_filter.dart'; import 'package:invoiceninja_flutter/ui/app/lists/selected_indicator.dart'; -import 'package:invoiceninja_flutter/data/models/entities.dart'; import 'package:invoiceninja_flutter/ui/settings/settings_list_vm.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; @@ -62,6 +61,11 @@ class SettingsList extends StatelessWidget { viewModel: viewModel, icon: FontAwesomeIcons.user, ), + SettingsListTile( + section: kSettingsUserDetails, + viewModel: viewModel, + icon: FontAwesomeIcons.user, + ), SettingsListTile( section: kSettingsLocalization, viewModel: viewModel, diff --git a/lib/utils/completers.dart b/lib/utils/completers.dart index 43e4bc019..942d6c216 100644 --- a/lib/utils/completers.dart +++ b/lib/utils/completers.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:invoiceninja_flutter/ui/app/dialogs/error_dialog.dart'; import 'package:invoiceninja_flutter/ui/app/snackbar_row.dart';