diff --git a/lib/main.dart b/lib/main.dart index 09e880183..e016ac07b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -61,6 +61,7 @@ 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:invoiceninja_flutter/ui/task/task_screen_vm.dart'; +import 'package:invoiceninja_flutter/ui/tax_rate/tax_rate_screen_vm.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; import 'package:local_auth/local_auth.dart'; import 'package:redux/redux.dart'; @@ -353,7 +354,7 @@ class InvoiceNinjaAppState extends State { CompanyGatewayEditScreen(), OnlinePaymentsScreen.route: (context) => OnlinePaymentsScreen(), TaxRatesScreen.route: (context) => TaxRatesScreen(), - TaxRateSettingsScreen.route: (context) => TaxRateSettingsScreen(), + TaxRateSettingsScreen.route: (context) => TaxRateScreenBuilder(), TaxRateViewScreen.route: (context) => TaxRateViewScreen(), TaxRateEditScreen.route: (context) => TaxRateEditScreen(), ProductSettingsScreen.route: (context) => ProductSettingsScreen(), diff --git a/lib/redux/tax_rate/tax_rate_actions.dart b/lib/redux/tax_rate/tax_rate_actions.dart index 0ef186992..4aaec74be 100644 --- a/lib/redux/tax_rate/tax_rate_actions.dart +++ b/lib/redux/tax_rate/tax_rate_actions.dart @@ -237,9 +237,20 @@ class FilterTaxRatesByEntity implements PersistUI { } void handleTaxRateAction( - BuildContext context, TaxRateEntity taxRate, EntityAction action) { + BuildContext context, List taxRates, EntityAction action) { + assert( + [ + EntityAction.restore, + EntityAction.archive, + EntityAction.delete, + EntityAction.toggleMultiselect + ].contains(action) || + taxRates.length == 1, + 'Cannot perform this action on more than one tax rate'); + final store = StoreProvider.of(context); final localization = AppLocalization.of(context); + final taxRate = taxRates.first; switch (action) { case EntityAction.edit: @@ -259,5 +270,50 @@ void handleTaxRateAction( store.dispatch(DeleteTaxRateRequest( snackBarCompleter(context, localization.deletedTaxRate), taxRate.id)); break; + case EntityAction.toggleMultiselect: + if (!store.state.taxRateListState.isInMultiselect()) { + store.dispatch(StartTaxRateMultiselect(context: context)); + } + + if (taxRates.isEmpty) { + break; + } + + for (final taxRate in taxRates) { + if (!store.state.taxRateListState.isSelected(taxRate)) { + store.dispatch( + AddToTaxRateMultiselect(context: context, entity: taxRate)); + } else { + store.dispatch( + RemoveFromTaxRateMultiselect(context: context, entity: taxRate)); + } + } + break; } } + +class StartTaxRateMultiselect { + StartTaxRateMultiselect({@required this.context}); + + final BuildContext context; +} + +class AddToTaxRateMultiselect { + AddToTaxRateMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class RemoveFromTaxRateMultiselect { + RemoveFromTaxRateMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class ClearTaxRateMultiselect { + ClearTaxRateMultiselect({@required this.context}); + + final BuildContext context; +} \ No newline at end of file diff --git a/lib/redux/tax_rate/tax_rate_reducer.dart b/lib/redux/tax_rate/tax_rate_reducer.dart index 581075a5b..ce823597a 100644 --- a/lib/redux/tax_rate/tax_rate_reducer.dart +++ b/lib/redux/tax_rate/tax_rate_reducer.dart @@ -52,6 +52,11 @@ final taxRateListReducer = combineReducers([ TypedReducer(_filterTaxRatesByCustom1), TypedReducer(_filterTaxRatesByCustom2), TypedReducer(_filterTaxRatesByClient), + TypedReducer(_startListMultiselect), + TypedReducer(_addToListMultiselect), + TypedReducer( + _removeFromListMultiselect), + TypedReducer(_clearListMultiselect), ]); ListUIState _filterTaxRatesByClient( @@ -106,6 +111,28 @@ ListUIState _sortTaxRates(ListUIState taxRateListState, SortTaxRates action) { ..sortField = action.field); } +ListUIState _startListMultiselect( + ListUIState taxRateListState, StartTaxRateMultiselect action) { + return taxRateListState.rebuild((b) => b..selectedEntities = []); +} + +ListUIState _addToListMultiselect( + ListUIState taxRateListState, AddToTaxRateMultiselect action) { + return taxRateListState + .rebuild((b) => b..selectedEntities.add(action.entity)); +} + +ListUIState _removeFromListMultiselect( + ListUIState taxRateListState, RemoveFromTaxRateMultiselect action) { + return taxRateListState + .rebuild((b) => b..selectedEntities.remove(action.entity)); +} + +ListUIState _clearListMultiselect( + ListUIState taxRateListState, ClearTaxRateMultiselect action) { + return taxRateListState.rebuild((b) => b..selectedEntities = null); +} + final taxRatesReducer = combineReducers([ TypedReducer(_updateTaxRate), TypedReducer(_addTaxRate), diff --git a/lib/ui/tax_rate/tax_rate_list.dart b/lib/ui/tax_rate/tax_rate_list.dart index 70b06d2c9..4438562c9 100644 --- a/lib/ui/tax_rate/tax_rate_list.dart +++ b/lib/ui/tax_rate/tax_rate_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/lists/list_divider.dart'; import 'package:invoiceninja_flutter/ui/app/loading_indicator.dart'; @@ -26,6 +28,9 @@ class TaxRateList extends StatelessWidget { final filteredClient = filteredClientId != null ? viewModel.clientMap[filteredClientId] : null; */ + final store = StoreProvider.of(context); + final listUIState = store.state.uiState.taxRateUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); return Column( children: [ @@ -63,10 +68,22 @@ class TaxRateList extends StatelessWidget { showDialog(); } else { viewModel.onEntityAction( - context, taxRate, action); + context, [taxRate], action); } }, - onLongPress: () => showDialog(), + onLongPress: () async { + final longPressIsSelection = store.state.uiState + .longPressSelectionIsDefault ?? + true; + if (longPressIsSelection && !isInMultiselect) { + viewModel.onEntityAction(context, [taxRate], + EntityAction.toggleMultiselect); + } else { + showDialog(); + } + }, + isChecked: isInMultiselect && + listUIState.isSelected(taxRate), ); }, ), diff --git a/lib/ui/tax_rate/tax_rate_list_item.dart b/lib/ui/tax_rate/tax_rate_list_item.dart index 49f72c0df..8ee6b0cd3 100644 --- a/lib/ui/tax_rate/tax_rate_list_item.dart +++ b/lib/ui/tax_rate/tax_rate_list_item.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/ui/app/dismissible_entity.dart'; -class TaxRateListItem extends StatelessWidget { +class TaxRateListItem extends StatefulWidget { const TaxRateListItem({ @required this.user, @required this.onEntityAction, @@ -17,6 +17,8 @@ class TaxRateListItem extends StatelessWidget { //@required this.onCheckboxChanged, @required this.taxRate, @required this.filter, + this.onCheckboxChanged, + this.isChecked = false, }); final UserEntity user; @@ -26,54 +28,78 @@ class TaxRateListItem extends StatelessWidget { //final ValueChanged onCheckboxChanged; final TaxRateEntity taxRate; final String filter; + final Function(bool) onCheckboxChanged; + final bool isChecked; static final taxRateItemKey = (int id) => Key('__tax_rate_item_${id}__'); + @override + _TaxRateListItemState createState() => _TaxRateListItemState(); +} + +class _TaxRateListItemState extends State + with TickerProviderStateMixin { @override Widget build(BuildContext context) { final store = StoreProvider.of(context); final state = store.state; final uiState = state.uiState; final taxRateUIState = uiState.taxRateUIState; + final listUIState = taxRateUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); + final showCheckbox = widget.onCheckboxChanged != null || isInMultiselect; - final filterMatch = filter != null && filter.isNotEmpty - ? taxRate.matchesFilterValue(filter) + if (isInMultiselect) { + _multiselectCheckboxAnimController.forward(); + } else { + _multiselectCheckboxAnimController.animateBack(0.0); + } + + final filterMatch = widget.filter != null && widget.filter.isNotEmpty + ? widget.taxRate.matchesFilterValue(widget.filter) : null; final subtitle = filterMatch; return DismissibleEntity( userCompany: state.userCompany, - entity: taxRate, - isSelected: taxRate.id == + entity: widget.taxRate, + isSelected: widget.taxRate.id == (uiState.isEditing ? taxRateUIState.editing.id : taxRateUIState.selectedId), - onEntityAction: onEntityAction, + onEntityAction: widget.onEntityAction, child: ListTile( - onTap: onTap, - onLongPress: onLongPress, - /* - leading: Checkbox( - //key: NinjaKeys.taxRateItemCheckbox(taxRate.id), - value: true, - //onChanged: onCheckboxChanged, - onChanged: (value) { - return true; - }, - ), - */ + onTap: isInMultiselect + ? () => widget.onEntityAction(EntityAction.toggleMultiselect) + : widget.onTap, + onLongPress: widget.onLongPress, + leading: showCheckbox + ? FadeTransition( + opacity: _multiselectCheckboxAnim, + child: IgnorePointer( + ignoring: listUIState.isInMultiselect(), + child: Checkbox( + //key: NinjaKeys.taxRateItemCheckbox(task.id), + 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( - taxRate.name, + widget.taxRate.name, //key: NinjaKeys.clientItemClientKey(client.id), style: Theme.of(context).textTheme.title, ), ), - Text(formatNumber(taxRate.listDisplayAmount, context), + Text(formatNumber(widget.taxRate.listDisplayAmount, context), style: Theme.of(context).textTheme.title), ], ), @@ -88,10 +114,28 @@ class TaxRateListItem extends StatelessWidget { overflow: TextOverflow.ellipsis, ) : Container(), - EntityStateLabel(taxRate), + EntityStateLabel(widget.taxRate), ], ), ), ); } + + 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/tax_rate/tax_rate_list_vm.dart b/lib/ui/tax_rate/tax_rate_list_vm.dart index da738c6ec..fabbf2c0c 100644 --- a/lib/ui/tax_rate/tax_rate_list_vm.dart +++ b/lib/ui/tax_rate/tax_rate_list_vm.dart @@ -80,8 +80,8 @@ class TaxRateListVM { store.dispatch(ViewTaxRate(taxRateId: taxRate.id, context: context)); }, onEntityAction: - (BuildContext context, BaseEntity taxRate, EntityAction action) => - handleTaxRateAction(context, taxRate, action), + (BuildContext context, List taxRates, EntityAction action) => + handleTaxRateAction(context, taxRates, action), onRefreshed: (context) => _handleRefresh(context), ); } @@ -95,7 +95,7 @@ class TaxRateListVM { final bool isLoaded; final Function(BuildContext, TaxRateEntity) onTaxRateTap; final Function(BuildContext) onRefreshed; - final Function(BuildContext, TaxRateEntity, EntityAction) onEntityAction; + final Function(BuildContext, List, EntityAction) onEntityAction; final Function onClearEntityFilterPressed; final Function(BuildContext) onViewEntityFilterPressed; } diff --git a/lib/ui/tax_rate/tax_rate_screen.dart b/lib/ui/tax_rate/tax_rate_screen.dart index fd691fec2..1d8eed59f 100644 --- a/lib/ui/tax_rate/tax_rate_screen.dart +++ b/lib/ui/tax_rate/tax_rate_screen.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; 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/ui/tax_rate/tax_rate_screen_vm.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; @@ -12,15 +14,37 @@ import 'package:invoiceninja_flutter/redux/tax_rate/tax_rate_actions.dart'; import 'package:invoiceninja_flutter/ui/app/app_bottom_bar.dart'; class TaxRateSettingsScreen extends StatelessWidget { + const TaxRateSettingsScreen({ + Key key, + @required this.viewModel, + }) : super(key: key); + static const String route = '/$kSettings/$kSettingsTaxRates'; + final TaxRateScreenVM viewModel; + @override Widget build(BuildContext context) { final store = StoreProvider.of(context); final state = store.state; final localization = AppLocalization.of(context); + final userCompany = state.userCompany; + final listUIState = state.uiState.taxRateUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); return AppScaffold( + isChecked: isInMultiselect && + listUIState.selectedEntities.length == viewModel.taxRateList.length, + showCheckbox: isInMultiselect, + onCheckboxChanged: (value) { + final taxRates = viewModel.taxRateList + .map((taxRateId) => viewModel.taxRateMap[taxRateId]) + .where((taxRate) => value != listUIState.isSelected(taxRate)) + .toList(); + + viewModel.onEntityAction( + context, taxRates, EntityAction.toggleMultiselect); + }, hideHamburgerButton: true, appBarTitle: ListFilter( key: ValueKey(state.taxRateListState.filterClearedAt), @@ -30,12 +54,44 @@ class TaxRateSettingsScreen extends StatelessWidget { }, ), appBarActions: [ - ListFilterButton( - entityType: EntityType.taxRate, - onFilterPressed: (String value) { - store.dispatch(FilterTaxRates(value)); - }, - ), + if (!viewModel.isInMultiselect) + ListFilterButton( + entityType: EntityType.taxRate, + onFilterPressed: (String value) { + store.dispatch(FilterTaxRates(value)); + }, + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + child: Text( + localization.cancel, + style: TextStyle(color: Colors.white), + ), + onPressed: () { + store.dispatch(ClearTaxRateMultiselect(context: context)); + }, + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + textColor: Colors.white, + disabledTextColor: Colors.white54, + child: Text( + localization.done, + ), + onPressed: state.taxRateListState.selectedEntities.isEmpty + ? null + : () async { + await showEntityActionsDialog( + entities: state.taxRateListState.selectedEntities, + userCompany: userCompany, + context: context, + onEntityAction: viewModel.onEntityAction, + multiselect: true); + store.dispatch(ClearTaxRateMultiselect(context: context)); + }, + ), ], body: TaxRateListBuilder(), bottomNavigationBar: AppBottomBar( diff --git a/lib/ui/tax_rate/tax_rate_screen_vm.dart b/lib/ui/tax_rate/tax_rate_screen_vm.dart new file mode 100644 index 000000000..8aecd6710 --- /dev/null +++ b/lib/ui/tax_rate/tax_rate_screen_vm.dart @@ -0,0 +1,59 @@ +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/tax_rate/tax_rate_actions.dart'; +import 'package:invoiceninja_flutter/redux/tax_rate/tax_rate_selectors.dart'; +import 'package:invoiceninja_flutter/ui/tax_rate/tax_rate_screen.dart'; +import 'package:redux/redux.dart'; + +class TaxRateScreenBuilder extends StatelessWidget { + const TaxRateScreenBuilder({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + //rebuildOnChange: true, + converter: TaxRateScreenVM.fromStore, + builder: (context, vm) { + return TaxRateSettingsScreen( + viewModel: vm, + ); + }, + ); + } +} + +class TaxRateScreenVM { + TaxRateScreenVM({ + @required this.isInMultiselect, + @required this.taxRateList, + @required this.userCompany, + @required this.onEntityAction, + @required this.taxRateMap, + }); + + final bool isInMultiselect; + final UserCompanyEntity userCompany; + final List taxRateList; + final Function(BuildContext, List, EntityAction) onEntityAction; + final BuiltMap taxRateMap; + + static TaxRateScreenVM fromStore(Store store) { + final state = store.state; + + return TaxRateScreenVM( + taxRateMap: state.taxRateState.map, + taxRateList: memoizedFilteredTaxRateList(state.taxRateState.map, + state.taxRateState.list, state.taxRateListState), + userCompany: state.userCompany, + isInMultiselect: state.taxRateListState.isInMultiselect(), + onEntityAction: (BuildContext context, List taxRates, + EntityAction action) => + handleTaxRateAction(context, taxRates, action), + ); + } +} diff --git a/lib/ui/tax_rate/view/tax_rate_view_vm.dart b/lib/ui/tax_rate/view/tax_rate_view_vm.dart index 0946dde50..c4a72b478 100644 --- a/lib/ui/tax_rate/view/tax_rate_view_vm.dart +++ b/lib/ui/tax_rate/view/tax_rate_view_vm.dart @@ -85,7 +85,7 @@ class TaxRateViewVM { } }, onEntityAction: (BuildContext context, EntityAction action) => - handleTaxRateAction(context, taxRate, action), + handleTaxRateAction(context, [taxRate], action), ); }