Implemented Multiselect for Group

This commit is contained in:
Gianfranco Gasbarri 2019-10-19 22:48:08 +01:00
parent c389866edb
commit 635d3a2d48
10 changed files with 314 additions and 49 deletions

View File

@ -42,6 +42,7 @@ import 'package:invoiceninja_flutter/ui/document/document_screen_vm.dart';
import 'package:invoiceninja_flutter/ui/expense/expense_screen_vm.dart';
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/product/product_screen_vm.dart';
import 'package:invoiceninja_flutter/ui/settings/buy_now_buttons_vm.dart';
@ -329,7 +330,7 @@ class InvoiceNinjaAppState extends State<InvoiceNinjaApp> {
QuoteEditScreen.route: (context) => QuoteEditScreen(),
QuoteEmailScreen.route: (context) => QuoteEmailScreen(),
// STARTER: routes - do not remove comment
GroupSettingsScreen.route: (context) => GroupSettingsScreen(),
GroupSettingsScreen.route: (context) => GroupScreenBuilder(),
GroupViewScreen.route: (context) => GroupViewScreen(),
GroupEditScreen.route: (context) => GroupEditScreen(),
SettingsScreen.route: (context) => SettingsScreen(),
@ -352,7 +353,7 @@ class InvoiceNinjaAppState extends State<InvoiceNinjaApp> {
NotificationsSettingsScreen(),
ImportExportScreen.route: (context) => ImportExportScreen(),
DeviceSettingsScreen.route: (context) => DeviceSettingsScreen(),
GroupSettingsScreen.route: (context) => GroupSettingsScreen(),
GroupSettingsScreen.route: (context) => GroupScreenBuilder(),
GroupEditScreen.route: (context) => GroupEditScreen(),
GroupViewScreen.route: (context) => GroupViewScreen(),
InvoiceSettingsScreen.route: (context) => InvoiceSettingsScreen(),

View File

@ -271,5 +271,50 @@ void handleGroupAction(
store.dispatch(DeleteGroupRequest(
snackBarCompleter(context, localization.deletedGroup), group.id));
break;
case EntityAction.toggleMultiselect:
if (!store.state.groupListState.isInMultiselect()) {
store.dispatch(StartGroupMultiselect(context: context));
}
if (groups.isEmpty) {
break;
}
for (final group in groups) {
if (!store.state.groupListState.isSelected(group)) {
store
.dispatch(AddToGroupMultiselect(context: context, entity: group));
} else {
store.dispatch(
RemoveFromGroupMultiselect(context: context, entity: group));
}
}
break;
}
}
class StartGroupMultiselect {
StartGroupMultiselect({@required this.context});
final BuildContext context;
}
class AddToGroupMultiselect {
AddToGroupMultiselect({@required this.context, @required this.entity});
final BuildContext context;
final BaseEntity entity;
}
class RemoveFromGroupMultiselect {
RemoveFromGroupMultiselect({@required this.context, @required this.entity});
final BuildContext context;
final BaseEntity entity;
}
class ClearGroupMultiselect {
ClearGroupMultiselect({@required this.context});
final BuildContext context;
}

View File

@ -1,11 +1,12 @@
import 'package:invoiceninja_flutter/data/models/entities.dart';
import 'package:invoiceninja_flutter/data/models/group_model.dart';
import 'package:invoiceninja_flutter/redux/app/app_actions.dart';
import 'package:redux/redux.dart';
import 'package:invoiceninja_flutter/redux/company/company_actions.dart';
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/group/group_actions.dart';
import 'package:invoiceninja_flutter/redux/group/group_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';
EntityUIState groupUIReducer(GroupUIState state, dynamic action) {
return state.rebuild((b) => b
@ -52,6 +53,11 @@ final groupListReducer = combineReducers<ListUIState>([
TypedReducer<ListUIState, FilterGroupsByCustom1>(_filterGroupsByCustom1),
TypedReducer<ListUIState, FilterGroupsByCustom2>(_filterGroupsByCustom2),
TypedReducer<ListUIState, FilterGroupsByEntity>(_filterGroupsByClient),
TypedReducer<ListUIState, StartGroupMultiselect>(_startListMultiselect),
TypedReducer<ListUIState, AddToGroupMultiselect>(_addToListMultiselect),
TypedReducer<ListUIState, RemoveFromGroupMultiselect>(
_removeFromListMultiselect),
TypedReducer<ListUIState, ClearGroupMultiselect>(_clearListMultiselect),
]);
ListUIState _filterGroupsByClient(
@ -104,6 +110,27 @@ ListUIState _sortGroups(ListUIState groupListState, SortGroups action) {
..sortField = action.field);
}
ListUIState _startListMultiselect(
ListUIState groupListState, StartGroupMultiselect action) {
return groupListState.rebuild((b) => b..selectedEntities = <BaseEntity>[]);
}
ListUIState _addToListMultiselect(
ListUIState groupListState, AddToGroupMultiselect action) {
return groupListState.rebuild((b) => b..selectedEntities.add(action.entity));
}
ListUIState _removeFromListMultiselect(
ListUIState groupListState, RemoveFromGroupMultiselect action) {
return groupListState
.rebuild((b) => b..selectedEntities.remove(action.entity));
}
ListUIState _clearListMultiselect(
ListUIState groupListState, ClearGroupMultiselect action) {
return groupListState.rebuild((b) => b..selectedEntities = null);
}
final groupsReducer = combineReducers<GroupState>([
TypedReducer<GroupState, SaveGroupSuccess>(_updateGroup),
TypedReducer<GroupState, AddGroupSuccess>(_addGroup),

View File

@ -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';
@ -26,6 +28,9 @@ class GroupList extends StatelessWidget {
final filteredClient =
filteredClientId != null ? viewModel.clientMap[filteredClientId] : null;
*/
final store = StoreProvider.of<AppState>(context);
final listUIState = store.state.uiState.groupUIState.listUIState;
final isInMultiselect = listUIState.isInMultiselect();
return Column(
children: <Widget>[
@ -64,7 +69,19 @@ class GroupList extends StatelessWidget {
context, [group], action);
}
},
onLongPress: () => showDialog(),
onLongPress: () async {
final longPressIsSelection = store.state.uiState
.longPressSelectionIsDefault ??
true;
if (longPressIsSelection && !isInMultiselect) {
viewModel.onEntityAction(context, [group],
EntityAction.toggleMultiselect);
} else {
showDialog();
}
},
isChecked: isInMultiselect &&
listUIState.isSelected(group),
);
},
),

View File

@ -1,14 +1,14 @@
import 'package:flutter_redux/flutter_redux.dart';
import 'package:invoiceninja_flutter/data/models/group_model.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/group_model.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 GroupListItem extends StatelessWidget {
class GroupListItem extends StatefulWidget {
const GroupListItem({
@required this.user,
@required this.onEntityAction,
@ -17,6 +17,8 @@ class GroupListItem extends StatelessWidget {
//@required this.onCheckboxChanged,
@required this.group,
@required this.filter,
this.onCheckboxChanged,
this.isChecked = false,
});
final UserEntity user;
@ -26,26 +28,47 @@ class GroupListItem extends StatelessWidget {
//final ValueChanged<bool> onCheckboxChanged;
final GroupEntity group;
final String filter;
final Function(bool) onCheckboxChanged;
final bool isChecked;
static final groupItemKey = (int id) => Key('__group_item_${id}__');
@override
_GroupListItemState createState() => _GroupListItemState();
}
class _GroupListItemState extends State<GroupListItem>
with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final store = StoreProvider.of<AppState>(context);
final state = store.state;
final filterMatch = filter != null && filter.isNotEmpty
? group.matchesFilterValue(filter)
final filterMatch = widget.filter != null && widget.filter.isNotEmpty
? widget.group.matchesFilterValue(widget.filter)
: null;
final subtitle = filterMatch;
final uiState = store.state.uiState;
final groupUIState = uiState.groupUIState;
final listUIState = groupUIState.listUIState;
final isInMultiselect = listUIState.isInMultiselect();
final showCheckbox = widget.onCheckboxChanged != null || isInMultiselect;
if (isInMultiselect) {
_multiselectCheckboxAnimController.forward();
} else {
_multiselectCheckboxAnimController.animateBack(0.0);
}
return DismissibleEntity(
userCompany: state.userCompany,
entity: group,
entity: widget.group,
isSelected: false,
onEntityAction: onEntityAction,
onEntityAction: widget.onEntityAction,
child: ListTile(
onTap: onTap,
onLongPress: onLongPress,
onTap: isInMultiselect
? () => widget.onEntityAction(EntityAction.toggleMultiselect)
: widget.onTap,
onLongPress: widget.onLongPress,
/*
leading: Checkbox(
//key: NinjaKeys.groupItemCheckbox(group.id),
@ -56,18 +79,33 @@ class GroupListItem extends StatelessWidget {
},
),
*/
leading: showCheckbox
? FadeTransition(
opacity: _multiselectCheckboxAnim,
child: IgnorePointer(
ignoring: listUIState.isInMultiselect(),
child: Checkbox(
//key: NinjaKeys.productItemCheckbox(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: <Widget>[
Expanded(
child: Text(
group.name,
widget.group.name,
//key: NinjaKeys.clientItemClientKey(client.id),
style: Theme.of(context).textTheme.title,
),
),
Text(formatNumber(group.listDisplayAmount, context),
Text(formatNumber(widget.group.listDisplayAmount, context),
style: Theme.of(context).textTheme.title),
],
),
@ -82,10 +120,28 @@ class GroupListItem extends StatelessWidget {
overflow: TextOverflow.ellipsis,
)
: Container(),
EntityStateLabel(group),
EntityStateLabel(widget.group),
],
),
),
);
}
Animation _multiselectCheckboxAnim;
AnimationController _multiselectCheckboxAnimController;
@override
void initState() {
super.initState();
_multiselectCheckboxAnimController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500));
_multiselectCheckboxAnim = Tween<double>(begin: 0.0, end: 1.0)
.animate(_multiselectCheckboxAnimController);
}
@override
void dispose() {
_multiselectCheckboxAnimController.dispose();
super.dispose();
}
}

View File

@ -1,20 +1,21 @@
import 'dart:async';
import 'package:invoiceninja_flutter/data/models/group_model.dart';
import 'package:redux/redux.dart';
import 'package:flutter/material.dart';
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:built_collection/built_collection.dart';
import 'package:invoiceninja_flutter/data/models/group_model.dart';
import 'package:invoiceninja_flutter/data/models/models.dart';
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
import 'package:invoiceninja_flutter/redux/client/client_actions.dart';
import 'package:invoiceninja_flutter/redux/group/group_actions.dart';
import 'package:invoiceninja_flutter/redux/group/group_selectors.dart';
import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart';
import 'package:invoiceninja_flutter/ui/group/group_list.dart';
import 'package:invoiceninja_flutter/utils/completers.dart';
import 'package:invoiceninja_flutter/utils/localization.dart';
import 'package:invoiceninja_flutter/redux/group/group_selectors.dart';
import 'package:invoiceninja_flutter/data/models/models.dart';
import 'package:invoiceninja_flutter/ui/group/group_list.dart';
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
import 'package:invoiceninja_flutter/redux/group/group_actions.dart';
import 'package:redux/redux.dart';
class GroupListBuilder extends StatelessWidget {
const GroupListBuilder({Key key}) : super(key: key);
@ -77,9 +78,9 @@ class GroupListVM {
onGroupTap: (context, group) {
store.dispatch(ViewGroup(groupId: group.id, context: context));
},
onEntityAction:
(BuildContext context, List<BaseEntity> group, EntityAction action) =>
handleGroupAction(context, group, action),
onEntityAction: (BuildContext context, List<BaseEntity> groups,
EntityAction action) =>
handleGroupAction(context, groups, action),
onRefreshed: (context) => _handleRefresh(context),
);
}

View File

@ -2,27 +2,52 @@ import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:invoiceninja_flutter/constants.dart';
import 'package:invoiceninja_flutter/data/models/group_model.dart';
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:invoiceninja_flutter/data/models/models.dart';
import 'package:invoiceninja_flutter/ui/group/group_list_vm.dart';
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
import 'package:invoiceninja_flutter/redux/group/group_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/group/group_list_vm.dart';
import 'package:invoiceninja_flutter/utils/localization.dart';
import 'group_screen_vm.dart';
class GroupSettingsScreen extends StatelessWidget {
const GroupSettingsScreen({
Key key,
@required this.viewModel,
}) : super(key: key);
static const String route = '/$kSettings/$kSettingsGroupSettings';
final GroupScreenVM viewModel;
@override
Widget build(BuildContext context) {
final store = StoreProvider.of<AppState>(context);
final state = store.state;
final company = state.selectedCompany;
final userCompany = state.userCompany;
final localization = AppLocalization.of(context);
final listUIState = state.uiState.groupUIState.listUIState;
final isInMultiselect = listUIState.isInMultiselect();
return AppScaffold(
isChecked: isInMultiselect &&
listUIState.selectedEntities.length == viewModel.groupList.length,
showCheckbox: isInMultiselect,
onCheckboxChanged: (value) {
final groups = viewModel.groupList
.map<GroupEntity>((groupId) => viewModel.groupMap[groupId])
.where((group) => value != listUIState.isSelected(group))
.toList();
viewModel.onEntityAction(
context, groups, EntityAction.toggleMultiselect);
},
hideHamburgerButton: true,
appBarTitle: ListFilter(
key: ValueKey(state.groupListState.filterClearedAt),
@ -32,12 +57,44 @@ class GroupSettingsScreen extends StatelessWidget {
},
),
appBarActions: [
ListFilterButton(
entityType: EntityType.group,
onFilterPressed: (String value) {
store.dispatch(FilterGroups(value));
},
),
if (!viewModel.isInMultiselect)
ListFilterButton(
entityType: EntityType.group,
onFilterPressed: (String value) {
store.dispatch(FilterGroups(value));
},
),
if (viewModel.isInMultiselect)
FlatButton(
key: key,
child: Text(
localization.cancel,
style: TextStyle(color: Colors.white),
),
onPressed: () {
store.dispatch(ClearGroupMultiselect(context: context));
},
),
if (viewModel.isInMultiselect)
FlatButton(
key: key,
textColor: Colors.white,
disabledTextColor: Colors.white54,
child: Text(
localization.done,
),
onPressed: state.groupListState.selectedEntities.isEmpty
? null
: () async {
await showEntityActionsDialog(
entities: state.groupListState.selectedEntities,
userCompany: userCompany,
context: context,
onEntityAction: viewModel.onEntityAction,
multiselect: true);
store.dispatch(ClearGroupMultiselect(context: context));
},
),
],
body: GroupListBuilder(),
bottomNavigationBar: AppBottomBar(

View File

@ -0,0 +1,61 @@
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/group_model.dart';
import 'package:invoiceninja_flutter/data/models/models.dart';
import 'package:invoiceninja_flutter/redux/app/app_state.dart';
import 'package:invoiceninja_flutter/redux/group/group_actions.dart';
import 'package:invoiceninja_flutter/redux/group/group_selectors.dart';
import 'package:redux/redux.dart';
import 'group_screen.dart';
class GroupScreenBuilder extends StatelessWidget {
const GroupScreenBuilder({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return StoreConnector<AppState, GroupScreenVM>(
//rebuildOnChange: true,
converter: GroupScreenVM.fromStore,
builder: (context, vm) {
return GroupSettingsScreen(
viewModel: vm,
);
},
);
}
}
class GroupScreenVM {
GroupScreenVM({
@required this.isInMultiselect,
@required this.groupList,
@required this.userCompany,
@required this.onEntityAction,
@required this.groupMap,
});
final bool isInMultiselect;
final UserCompanyEntity userCompany;
final List<String> groupList;
final Function(BuildContext, List<BaseEntity>, EntityAction) onEntityAction;
final BuiltMap<String, GroupEntity> groupMap;
static GroupScreenVM fromStore(Store<AppState> store) {
final state = store.state;
return GroupScreenVM(
groupMap: state.groupState.map,
groupList: memoizedFilteredGroupList(
state.groupState.map, state.groupState.list, state.groupListState),
userCompany: state.userCompany,
isInMultiselect: state.groupListState.isInMultiselect(),
onEntityAction: (BuildContext context, List<BaseEntity> groups,
EntityAction action) =>
handleGroupAction(context, groups, action),
);
}
}

View File

@ -40,13 +40,13 @@ class _ProductListItemState extends State<ProductListItem>
with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
final store = StoreProvider.of<AppState>(context);
final uiState = store.state.uiState;
final productUIState = uiState.productUIState;
final filterMatch = widget.filter != null && widget.filter.isNotEmpty
? widget.product.matchesFilterValue(widget.filter)
: null;
final subtitle = filterMatch ?? widget.product.notes;
final store = StoreProvider.of<AppState>(context);
final uiState = store.state.uiState;
final productUIState = uiState.productUIState;
final listUIState = productUIState.listUIState;
final isInMultiselect = listUIState.isInMultiselect();
final showCheckbox = widget.onCheckboxChanged != null || isInMultiselect;

View File

@ -26,10 +26,10 @@ class ProductScreen extends StatelessWidget {
Widget build(BuildContext context) {
final store = StoreProvider.of<AppState>(context);
final state = store.state;
final listUIState = state.uiState.productUIState.listUIState;
final company = state.selectedCompany;
final userCompany = state.userCompany;
final localization = AppLocalization.of(context);
final listUIState = state.uiState.productUIState.listUIState;
final isInMultiselect = listUIState.isInMultiselect();
return AppScaffold(