diff --git a/stubs/redux/stub/stub_actions b/stubs/redux/stub/stub_actions index 51fc42e9c..5d4a278b7 100644 --- a/stubs/redux/stub/stub_actions +++ b/stubs/redux/stub/stub_actions @@ -242,11 +242,12 @@ class FilterStubsByEntity implements PersistUI { void handleStubAction( - BuildContext context, StubEntity stub, EntityAction action) { + BuildContext context, List stubs, EntityAction action) { final store = StoreProvider.of(context); final state = store.state; final CompanyEntity company = state.selectedCompany; final localization = AppLocalization.of(context); + final stub = stubs.first as StubEntity; switch (action) { case EntityAction.edit: @@ -264,5 +265,50 @@ void handleStubAction( store.dispatch(DeleteStubRequest( snackBarCompleter(context, localization.deletedStub), stub.id)); break; + case EntityAction.toggleMultiselect: + if (!store.state.stubListState.isInMultiselect()) { + store.dispatch(StartStubMultiselect(context: context)); + } + + if (stubs.isEmpty) { + break; + } + + for (final stub in stubs) { + if (!store.state.stubListState.isSelected(stub)) { + store.dispatch( + AddToStubMultiselect(context: context, entity: stub)); + } else { + store.dispatch( + RemoveFromStubMultiselect(context: context, entity: stub)); + } + } + break; } -} \ No newline at end of file +} + +class StartStubMultiselect { + StartStubMultiselect({@required this.context}); + + final BuildContext context; +} + +class AddToStubMultiselect { + AddToStubMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class RemoveFromStubMultiselect { + RemoveFromStubMultiselect({@required this.context, @required this.entity}); + + final BuildContext context; + final BaseEntity entity; +} + +class ClearStubMultiselect { + ClearStubMultiselect({@required this.context}); + + final BuildContext context; +} diff --git a/stubs/redux/stub/stub_reducer b/stubs/redux/stub/stub_reducer index 1392d77e6..a9afb5e02 100644 --- a/stubs/redux/stub/stub_reducer +++ b/stubs/redux/stub/stub_reducer @@ -6,6 +6,7 @@ import 'package:invoiceninja_flutter/redux/ui/entity_ui_state.dart'; import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart'; import 'package:invoiceninja_flutter/redux/stub/stub_actions.dart'; import 'package:invoiceninja_flutter/redux/stub/stub_state.dart'; +import 'package:invoiceninja_flutter/data/models/entities.dart'; EntityUIState stubUIReducer(StubUIState state, dynamic action) { return state.rebuild((b) => b @@ -52,6 +53,11 @@ final stubListReducer = combineReducers([ TypedReducer(_filterStubsByCustom1), TypedReducer(_filterStubsByCustom2), TypedReducer(_filterStubsByClient), + TypedReducer(_startListMultiselect), + TypedReducer(_addToListMultiselect), + TypedReducer( + _removeFromListMultiselect), + TypedReducer(_clearListMultiselect), ]); ListUIState _filterStubsByClient( @@ -103,6 +109,28 @@ ListUIState _sortStubs(ListUIState stubListState, SortStubs action) { ..sortField = action.field); } +ListUIState _startListMultiselect( + ListUIState productListState, StartProductMultiselect action) { + return productListState.rebuild((b) => b..selectedEntities = []); +} + +ListUIState _addToListMultiselect( + ListUIState productListState, AddToProductMultiselect action) { + return productListState + .rebuild((b) => b..selectedEntities.add(action.entity)); +} + +ListUIState _removeFromListMultiselect( + ListUIState productListState, RemoveFromProductMultiselect action) { + return productListState + .rebuild((b) => b..selectedEntities.remove(action.entity)); +} + +ListUIState _clearListMultiselect( + ListUIState productListState, ClearProductMultiselect action) { + return productListState.rebuild((b) => b..selectedEntities = null); +} + final stubsReducer = combineReducers([ TypedReducer(_updateStub), TypedReducer(_addStub), diff --git a/stubs/ui/stub/stub_list b/stubs/ui/stub/stub_list index 0d56eac8e..7ec850da5 100644 --- a/stubs/ui/stub/stub_list +++ b/stubs/ui/stub/stub_list @@ -28,6 +28,9 @@ class StubList extends StatelessWidget { final filteredClient = filteredClientId != null ? viewModel.clientMap[filteredClientId] : null; */ + final store = StoreProvider.of(context); + final listUIState = store.state.uiState.stubUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); return Column( children: [ @@ -153,8 +156,17 @@ class StubList extends StatelessWidget { context, stub, action); } }, - onLongPress: () => - showDialog(), + onLongPress: () async { + final longPressIsSelection = + store.state.uiState.longPressSelectionIsDefault ?? true; + if (longPressIsSelection && !isInMultiselect) { + viewModel.onEntityAction( + context, [stub], EntityAction.toggleMultiselect); + } else { + showDialog(); + } + }, + isChecked: isInMultiselect && listUIState.isSelected(stub), ); }, ), diff --git a/stubs/ui/stub/stub_list_item b/stubs/ui/stub/stub_list_item index b6f6eb092..3984da4c0 100644 --- a/stubs/ui/stub/stub_list_item +++ b/stubs/ui/stub/stub_list_item @@ -8,16 +8,16 @@ import 'package:flutter/material.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/ui/app/dismissible_entity.dart'; -class StubListItem extends StatelessWidget { - +class StubListItem extends StatefulWidget { const StubListItem({ @required this.user, @required this.onEntityAction, @required this.onTap, @required this.onLongPress, - //@required this.onCheckboxChanged, @required this.stub, @required this.filter, + this.onCheckboxChanged, + this.isChecked = false, }); final UserEntity user; @@ -27,54 +27,76 @@ class StubListItem extends StatelessWidget { //final ValueChanged onCheckboxChanged; final StubEntity stub; final String filter; + final Function(bool) onCheckboxChanged; + final bool isChecked; static final stubItemKey = (int id) => Key('__stub_item_${id}__'); + @override + _StubListItemState createState() => _StubListItemState(); +} + +class _StubListItemState extends State + with TickerProviderStateMixin { @override Widget build(BuildContext context) { final store = StoreProvider.of(context); final state = store.state; final uiState = state.uiState; final stubUIState = uiState.stubUIState; + final listUIState = stubUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); + final showCheckbox = widget.onCheckboxChanged != null || isInMultiselect; - final filterMatch = filter != null && filter.isNotEmpty - ? stub.matchesFilterValue(filter) + if (isInMultiselect) { + _multiselectCheckboxAnimController.forward(); + } else { + _multiselectCheckboxAnimController.animateBack(0.0); + } + + final filterMatch = widget.filter != null && widget.filter.isNotEmpty + ? widget.stub.matchesFilterValue(widget.filter) : null; final subtitle = filterMatch; return DismissibleEntity( - userCompany: state.userCompany, - entity: stub, - isSelected: stub.id == - (uiState.isEditing - ? stubUIState.editing.id - : stubUIState.selectedId), - onEntityAction: onEntityAction, + userCompany: state.userCompany, + entity: widget.stub, + isSelected: widget.stub.id == + (uiState.isEditing ? stubUIState.editing.id : stubUIState.selectedId), + onEntityAction: widget.onEntityAction, child: ListTile( - onTap: onTap, - onLongPress: onLongPress, - /* - leading: Checkbox( - //key: NinjaKeys.stubItemCheckbox(stub.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.stubItemCheckbox(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( - stub.name, + widget.stub.name, //key: NinjaKeys.clientItemClientKey(client.id), style: Theme.of(context).textTheme.title, ), ), - Text(formatNumber(stub.listDisplayAmount, context), + Text(formatNumber(widget.stub.listDisplayAmount, context), style: Theme.of(context).textTheme.title), ], ), @@ -82,16 +104,35 @@ class StubListItem extends StatelessWidget { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - subtitle != null && subtitle.isNotEmpty ? - Text( - subtitle, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ) : Container(), - EntityStateLabel(stub), + subtitle != null && subtitle.isNotEmpty + ? Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : Container(), + EntityStateLabel(widget.stub), ], ), ), ); } + + 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/stubs/ui/stub/stub_list_vm b/stubs/ui/stub/stub_list_vm index 128dceecd..78399445f 100644 --- a/stubs/ui/stub/stub_list_vm +++ b/stubs/ui/stub/stub_list_vm @@ -80,8 +80,8 @@ class StubListVM { store.dispatch(ViewStub(stubId: stub.id, context: context)); }, onEntityAction: - (BuildContext context, BaseEntity stub, EntityAction action) => - handleStubAction(context, stub, action), + (BuildContext context, List stubs, EntityAction action) => + handleStubAction(context, stubs, action), onRefreshed: (context) => _handleRefresh(context), ); } @@ -95,7 +95,7 @@ class StubListVM { final bool isLoaded; final Function(BuildContext, StubEntity) onStubTap; final Function(BuildContext) onRefreshed; - final Function(BuildContext, StubEntity, EntityAction) onEntityAction; + final Function(BuildContext, List, EntityAction) onEntityAction; final Function onClearEntityFilterPressed; final Function(BuildContext) onViewEntityFilterPressed; diff --git a/stubs/ui/stub/stub_screen b/stubs/ui/stub/stub_screen index d82f2af50..7b05c9da8 100644 --- a/stubs/ui/stub/stub_screen +++ b/stubs/ui/stub/stub_screen @@ -12,18 +12,40 @@ import 'package:invoiceninja_flutter/ui/stub/stub_list_vm.dart'; import 'package:invoiceninja_flutter/redux/stub/stub_actions.dart'; import 'package:invoiceninja_flutter/ui/app/app_drawer_vm.dart'; import 'package:invoiceninja_flutter/ui/app/app_bottom_bar.dart'; +import 'package:invoiceninja_flutter/ui/app/entities/entity_actions_dialog.dart'; class StubScreen extends StatelessWidget { + const StubScreen({ + Key key, + @required this.viewModel, + }) : super(key: key); + static const String route = '/stub'; + final StubScreenVM viewModel; + @override Widget build(BuildContext context) { final store = StoreProvider.of(context); final state = store.state; final company = state.selectedCompany; final localization = AppLocalization.of(context); + final listUIState = state.uiState.stubUIState.listUIState; + final isInMultiselect = listUIState.isInMultiselect(); return AppScaffold( + isChecked: isInMultiselect && + listUIState.selectedEntities.length == viewModel.stubList.length, + showCheckbox: isInMultiselect, + onCheckboxChanged: (value) { + final stubs = viewModel.stubList + .map((stubId) => viewModel.stubMap[stubId]) + .where((stub) => value != listUIState.isSelected(stub)) + .toList(); + + viewModel.onEntityAction( + context, stubs, EntityAction.toggleMultiselect); + }, appBarTitle: ListFilter( key: ValueKey(state.stubListState.filterClearedAt), entityType: EntityType.stub, @@ -32,13 +54,45 @@ class StubScreen extends StatelessWidget { }, ), appBarActions: [ - ListFilterButton( - entityType: EntityType.stub, - onFilterPressed: (String value) { - store.dispatch(FilterStubs(value)); - }, - ), - ], + if (!viewModel.isInMultiselect) + ListFilterButton( + entityType: EntityType.stub, + onFilterPressed: (String value) { + store.dispatch(FilterStubs(value)); + }, + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + child: Text( + localization.cancel, + style: TextStyle(color: Colors.white), + ), + onPressed: () { + store.dispatch(ClearStubMultiselect(context: context)); + }, + ), + if (viewModel.isInMultiselect) + FlatButton( + key: key, + textColor: Colors.white, + disabledTextColor: Colors.white54, + child: Text( + localization.done, + ), + onPressed: state.stubListState.selectedEntities.isEmpty + ? null + : () async { + await showEntityActionsDialog( + entities: state.stubListState.selectedEntities, + userCompany: userCompany, + context: context, + onEntityAction: viewModel.onEntityAction, + multiselect: true); + store.dispatch(ClearStubMultiselect(context: context)); + }, + ), + ], body: StubListBuilder(), bottomNavigationBar: AppBottomBar( entityType: EntityType.stub, diff --git a/stubs/ui/stub/stub_screen_vm b/stubs/ui/stub/stub_screen_vm new file mode 100644 index 000000000..64a8acac4 --- /dev/null +++ b/stubs/ui/stub/stub_screen_vm @@ -0,0 +1,60 @@ +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/stub/stub_actions.dart'; +import 'package:invoiceninja_flutter/redux/stub/stub_selectors.dart'; +import 'package:redux/redux.dart'; + +import 'stub_screen.dart'; + +class StubScreenBuilder extends StatelessWidget { + const StubScreenBuilder({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + //rebuildOnChange: true, + converter: StubScreenVM.fromStore, + builder: (context, vm) { + return StubScreen( + viewModel: vm, + ); + }, + ); + } +} + +class StubScreenVM { + StubScreenVM({ + @required this.isInMultiselect, + @required this.stubList, + @required this.userCompany, + @required this.onEntityAction, + @required this.stubMap, + }); + + final bool isInMultiselect; + final UserCompanyEntity userCompany; + final List stubList; + final Function(BuildContext, List, EntityAction) onEntityAction; + final BuiltMap stubMap; + + static StubScreenVM fromStore(Store store) { + final state = store.state; + + return StubScreenVM( + stubMap: state.stubState.map, + stubList: memoizedFilteredStubList(state.stubState.map, + state.stubState.list, state.stubListState), + userCompany: state.userCompany, + isInMultiselect: state.stubListState.isInMultiselect(), + onEntityAction: (BuildContext context, List stubs, + EntityAction action) => + handleStubAction(context, stubs, action), + ); + } +}