// Flutter imports: import 'package:flutter/material.dart' hide DataRow, DataCell, DataColumn; import 'package:flutter/material.dart' as mt; // Package imports: import 'package:flutter_redux/flutter_redux.dart'; // Project imports: import 'package:invoiceninja_flutter/.env.dart'; import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/data/models/dashboard_model.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; import 'package:invoiceninja_flutter/main_app.dart'; import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/redux/dashboard/dashboard_actions.dart'; import 'package:invoiceninja_flutter/redux/reports/reports_actions.dart'; import 'package:invoiceninja_flutter/redux/reports/reports_state.dart'; import 'package:invoiceninja_flutter/redux/ui/pref_state.dart'; import 'package:invoiceninja_flutter/ui/app/actions_menu_button.dart'; import 'package:invoiceninja_flutter/ui/app/app_border.dart'; import 'package:invoiceninja_flutter/ui/app/buttons/app_text_button.dart'; import 'package:invoiceninja_flutter/ui/app/buttons/elevated_button.dart'; import 'package:invoiceninja_flutter/ui/app/dialogs/multiselect_dialog.dart'; import 'package:invoiceninja_flutter/ui/app/form_card.dart'; import 'package:invoiceninja_flutter/ui/app/forms/app_dropdown_button.dart'; import 'package:invoiceninja_flutter/ui/app/forms/date_picker.dart'; import 'package:invoiceninja_flutter/ui/app/forms/decorated_form_field.dart'; import 'package:invoiceninja_flutter/ui/app/help_text.dart'; import 'package:invoiceninja_flutter/ui/app/history_drawer_vm.dart'; import 'package:invoiceninja_flutter/ui/app/menu_drawer_vm.dart'; import 'package:invoiceninja_flutter/ui/app/presenters/entity_presenter.dart'; import 'package:invoiceninja_flutter/ui/app/scrollable_listview.dart'; import 'package:invoiceninja_flutter/ui/app/tables/app_data_table.dart'; import 'package:invoiceninja_flutter/ui/app/tables/app_data_table_source.dart'; import 'package:invoiceninja_flutter/ui/app/tables/app_paginated_data_table.dart'; import 'package:invoiceninja_flutter/ui/app/upgrade_dialog.dart'; import 'package:invoiceninja_flutter/ui/reports/report_charts.dart'; import 'package:invoiceninja_flutter/ui/reports/reports_screen_vm.dart'; import 'package:invoiceninja_flutter/utils/colors.dart'; import 'package:invoiceninja_flutter/utils/dates.dart'; import 'package:invoiceninja_flutter/utils/dialogs.dart'; import 'package:invoiceninja_flutter/utils/formatting.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; import 'package:invoiceninja_flutter/utils/platforms.dart'; import 'package:invoiceninja_flutter/utils/strings.dart'; import 'package:url_launcher/url_launcher.dart'; class ReportsScreen extends StatelessWidget { const ReportsScreen({ Key key, @required this.viewModel, }) : super(key: key); static const String route = '/reports'; final ReportsScreenVM viewModel; @override Widget build(BuildContext context) { final localization = AppLocalization.of(context); final store = StoreProvider.of(context); final state = viewModel.state; final reportsState = viewModel.reportState; final reportResult = viewModel.reportResult; Widget leading = SizedBox(); final hideReports = state.isHosted && !state.isProPlan && !state.isTrial; if (isMobile(context) || state.prefState.isMenuFloated) { leading = Builder( builder: (context) => InkWell( child: IconButton( tooltip: localization.menuSidebar, icon: Icon(Icons.menu), onPressed: () { Scaffold.of(context).openDrawer(); }, ), ), ); } final hasCustomDate = reportsState.filters.keys.where((column) { final filter = reportsState.filters[column]; return (getReportColumnType(column, context) == ReportColumnType.dateTime || getReportColumnType(column, context) == ReportColumnType.date) && filter == DateRange.custom.toString(); }).isNotEmpty; final reports = [ kReportClient, kReportContact, if (state.company.isModuleEnabled(EntityType.invoice)) ...[ kReportInvoice, kReportInvoiceItem, kReportPayment, if (state.company.hasTaxes) ...[ kReportInvoiceTax, kReportPaymentTax, ], if (state.company.isModuleEnabled(EntityType.recurringInvoice)) kReportRecurringInvoice, ], if (state.company.isModuleEnabled(EntityType.quote)) ...[ kReportQuote, kReportQuoteItem, ], if (state.company.isModuleEnabled(EntityType.credit)) kReportCredit, kReportDocument, if (state.company.isModuleEnabled(EntityType.expense)) ...[ kReportExpense, if (state.company.isModuleEnabled(EntityType.recurringExpense)) kReportRecurringExpense, ], kReportProduct, kReportProfitAndLoss, kReportTask, if (state.company.isModuleEnabled(EntityType.vendor)) ...[ kReportVendor, if (state.company.isModuleEnabled(EntityType.purchaseOrder)) kReportPurchaseOrder, kReportPurchaseOrderItem, ], if (state.company.isModuleEnabled(EntityType.transaction)) kReportTransaction, ]..sort((a, b) => a.compareTo(b)); final reportChildren = [ AppDropdownButton( labelText: localization.report, value: reportsState.report, onChanged: (dynamic value) => viewModel.onSettingsChanged(report: value), items: reports .map((report) => DropdownMenuItem( value: report, child: Text(localization.lookup(report)), )) .toList(), ), AppDropdownButton( labelText: localization.group, value: reportsState.group, blankValue: '', showBlank: true, onChanged: (dynamic value) { viewModel.onSettingsChanged(group: value, selectedGroup: ''); }, items: reportResult.columns .where((column) => getReportColumnType(column, context) != ReportColumnType.number) .map((column) { final columnTitle = state.company.getCustomFieldLabel(column); return DropdownMenuItem( child: Text(columnTitle.isEmpty ? localization.lookup(column) : columnTitle), value: column, ); }).toList(), ), if (getReportColumnType(reportsState.group, context) == ReportColumnType.dateTime || getReportColumnType(reportsState.group, context) == ReportColumnType.date) AppDropdownButton( labelText: localization.subgroup, value: reportsState.subgroup, onChanged: (dynamic value) { viewModel.onSettingsChanged(subgroup: value); }, items: [ DropdownMenuItem( child: Text(localization.day), value: kReportGroupDay, ), DropdownMenuItem( child: Text(localization.week), value: kReportGroupWeek, ), DropdownMenuItem( child: Text(localization.month), value: kReportGroupMonth, ), DropdownMenuItem( child: Text(localization.year), value: kReportGroupYear, ), ], ), ]; final reportState = viewModel.reportState; final filterColumns = reportState.filters.keys.where((column) => [ ReportColumnType.date, ReportColumnType.dateTime, ].contains(getReportColumnType(column, context)) && (reportState.filters[column] ?? '').isNotEmpty); final dateColumns = reportResult.columns.where((column) => [ ReportColumnType.date, ReportColumnType.dateTime, ].contains(getReportColumnType(column, context))); final dateField = filterColumns.isNotEmpty ? filterColumns.first : null; final dateRange = (reportState.filters[dateField] ?? '').isNotEmpty ? DateRange.valueOf(reportState.filters[dateField]) : null; final dateChildren = [ if (dateColumns.length > 1) AppDropdownButton( labelText: localization.date, value: dateField, showBlank: true, onChanged: (dynamic value) { viewModel.onReportFiltersChanged( context, reportState.filters.rebuild((b) => b ..addAll( (value ?? '').isEmpty ? { if (filterColumns.isNotEmpty) filterColumns.first: '' } : { value: (reportState.filters[value] ?? '').isNotEmpty ? reportState.filters[value] : DateRange.thisQuarter.toString(), if (filterColumns.isNotEmpty && filterColumns.first != value) filterColumns.first: '' }, )), ); }, items: dateColumns .map((column) => DropdownMenuItem( value: column, child: Text( localization.lookup(column), ), )) .toList()), AppDropdownButton( labelText: localization.range, showBlank: true, blankValue: null, value: dateRange, onChanged: dateColumns.isEmpty ? null : (dynamic value) { viewModel.onReportFiltersChanged( context, reportState.filters.rebuild((b) => b ..addAll({ dateField ?? dateColumns.first: value == null ? '' : '$value' }))); }, items: DateRange.values .map((dateRange) => DropdownMenuItem( child: Text(localization.lookup(dateRange.toString())), value: dateRange, )) .toList(), ), if (hasCustomDate) ...[ DatePicker( labelText: localization.startDate, selectedDate: reportsState.customStartDate, onSelected: (date, _) => viewModel.onSettingsChanged(customStartDate: date), ), DatePicker( labelText: localization.endDate, selectedDate: reportsState.customEndDate, onSelected: (date, _) => viewModel.onSettingsChanged(customEndDate: date), ), ] ]; final cappedEntities = [...reportResult.entities ?? []]; final firstEntity = cappedEntities.isNotEmpty ? cappedEntities.first : null; if (cappedEntities.length > kMaxEntitiesPerBulkAction) { cappedEntities.removeRange( kMaxEntitiesPerBulkAction, cappedEntities.length); } final chartChildren = [ AppDropdownButton( enabled: reportsState.group.isNotEmpty, labelText: localization.chart, value: reportsState.group.isNotEmpty ? reportsState.chart : null, blankValue: '', showBlank: true, onChanged: (dynamic value) { viewModel.onSettingsChanged(chart: value); }, items: reportResult.columns .where((column) => [ ReportColumnType.number, ReportColumnType.age, ReportColumnType.duration, ].contains(getReportColumnType(column, context))) .map((column) => DropdownMenuItem( child: Text(localization.lookup(column)), value: column, )) .toList(), ), ]; return WillPopScope( onWillPop: () async { store.dispatch(ViewDashboard()); return false; }, child: Scaffold( drawer: isMobile(context) || state.prefState.isMenuFloated ? MenuDrawerBuilder() : null, endDrawer: isMobile(context) || state.prefState.isHistoryFloated ? HistoryDrawerBuilder() : null, appBar: AppBar( centerTitle: false, automaticallyImplyLeading: false, leadingWidth: isMobile(context) ? kMinInteractiveDimension : 0, leading: leading, title: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded(child: Text(localization.reports)), if (state.isSaving) SizedBox( width: 28, height: 28, child: CircularProgressIndicator(), ), ], ), actions: hideReports ? [] : [ if (isDesktop(context)) ...[ Builder(builder: (BuildContext context) { return AppTextButton( label: localization.columns, isInHeader: true, onPressed: () { multiselectDialog( // Using the navigatorKey to prevent using the appBarTheme context: navigatorKey.currentContext, onSelected: (selected) { viewModel.onReportColumnsChanged( context, selected); }, options: reportResult.allColumns, selected: reportResult.columns.toList(), defaultSelected: reportResult.defaultColumns, ); }, ); }), AppTextButton( label: localization.export, isInHeader: true, onPressed: () { viewModel.onExportPressed(context); }, ), ], Padding( padding: const EdgeInsets.only(right: 8), child: ActionMenuButton( entityActions: firstEntity == null ? null : firstEntity.getActions( userCompany: state.userCompany, multiselect: true), entity: firstEntity, onSelected: (context, action) { final entities = action.applyMaxLimit ? cappedEntities : reportResult.entities; confirmCallback( context: context, message: localization.lookup(action.toString()) + ' • ' + (entities.length == 1 ? '1 ${localization.lookup(firstEntity.entityType.toString())}' : '${entities.length} ${localization.lookup(firstEntity.entityType.plural)}'), callback: (_) { handleEntitiesActions(entities, action); }); }), ), if (isMobile(context) || !state.prefState.isHistoryVisible) Builder( builder: (context) => IconButton( icon: Icon(Icons.history), padding: const EdgeInsets.only(left: 4, right: 20), tooltip: state.prefState.enableTooltips ? localization.history : null, onPressed: () { if (isMobile(context) || state.prefState.isHistoryFloated) { Scaffold.of(context).openEndDrawer(); } else { store.dispatch(UpdateUserPreferences( sidebar: AppSidebar.history)); } }, ), ), ], ), body: hideReports ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ HelpText(localization.upgradeToViewReports), AppButton( label: localization.upgrade.toUpperCase(), onPressed: () { if (supportsInAppPurchase()) { showDialog( context: context, builder: (context) => UpgradeDialog(), ); } else { launchUrl( Uri.parse(state.userCompany.ninjaPortalUrl)); } }) ], ), ) : ScrollableListView( primary: true, key: ValueKey( '${viewModel.state.company.id}_${viewModel.state.isSaving}_${reportsState.report}_${reportsState.group}'), children: [ isMobile(context) ? FormCard( children: [ ...reportChildren, ...dateChildren, ...chartChildren, ], ) : Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible( child: FormCard( children: reportChildren, padding: const EdgeInsets.only( top: kMobileDialogPadding, right: kMobileDialogPadding / 2, left: kMobileDialogPadding), ), ), Flexible( child: FormCard( children: dateChildren, padding: const EdgeInsets.only( top: kMobileDialogPadding, right: kMobileDialogPadding / 2, left: kMobileDialogPadding / 2), ), ), Flexible( child: FormCard( children: chartChildren, padding: const EdgeInsets.only( top: kMobileDialogPadding, right: kMobileDialogPadding, left: kMobileDialogPadding / 2), ), ) ], ), if (isMobile(context)) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Builder(builder: (BuildContext context) { return Expanded( child: AppButton( label: localization.columns, onPressed: () { multiselectDialog( context: context, onSelected: (selected) { viewModel.onReportColumnsChanged( context, selected); }, options: reportResult.allColumns, selected: reportResult.columns.toList(), defaultSelected: reportResult.defaultColumns, ); }, ), ); }), SizedBox(width: kGutterWidth), Expanded( child: AppButton( label: localization.export, onPressed: () { viewModel.onExportPressed(context); }, ), ), ], ), ), ReportDataTable( key: ValueKey( '${viewModel.state.isSaving}_${reportsState.group}_${reportsState.selectedGroup}'), viewModel: viewModel, ) ], ), ), ); } } class ReportDataTable extends StatefulWidget { const ReportDataTable({Key key, @required this.viewModel}) : super(key: key); final ReportsScreenVM viewModel; @override _ReportDataTableState createState() => _ReportDataTableState(); } class _ReportDataTableState extends State { final Map> _textEditingControllers = {}; final Map> _textEditingFocusNodes = {}; ReportDataTableSource dataTableSource; @override void initState() { super.initState(); final viewModel = widget.viewModel; dataTableSource = ReportDataTableSource( viewModel: viewModel, context: context, textEditingControllers: _textEditingControllers, textEditingFocusNodes: _textEditingFocusNodes, onFilterChanged: (column, value) { final reportState = widget.viewModel.reportState; viewModel.onReportFiltersChanged(context, reportState.filters.rebuild((b) => b..addAll({column: value}))); }); } @override void didUpdateWidget(ReportDataTable oldWidget) { super.didUpdateWidget(oldWidget); final viewModel = widget.viewModel; dataTableSource.viewModel = viewModel; /* dataTableSource.editingId = viewModel.state.productUIState.editing.id; dataTableSource.entityList = viewModel.productList; dataTableSource.entityMap = viewModel.productMap; */ // ignore: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member dataTableSource.notifyListeners(); } @override void didChangeDependencies() { final viewModel = widget.viewModel; final reportState = viewModel.reportState; final reportResult = viewModel.reportResult; for (var column in reportResult.columns) { if (_textEditingControllers[reportState.report] == null) { _textEditingControllers[reportState.report] = {}; _textEditingFocusNodes[reportState.report] = {}; } if (!_textEditingControllers[reportState.report].containsKey(column)) { final textEditingController = TextEditingController(); // TODO figure out how to remove this listener in dispose textEditingController.addListener(() { _onChanged(column, textEditingController.text); }); if (reportState.filters.containsKey(column)) { textEditingController.text = reportState.filters[column]; } _textEditingControllers[reportState.report][column] = textEditingController; _textEditingFocusNodes[reportState.report][column] = FocusNode(); } } super.didChangeDependencies(); } void _onChanged(String column, String value) { widget.viewModel.onReportFiltersChanged( context, widget.viewModel.reportState.filters .rebuild((b) => b..addAll({column: value}))); } @override void dispose() { _textEditingControllers.keys.forEach((i) { _textEditingControllers[i].keys.forEach((j) { _textEditingControllers[i][j].dispose(); _textEditingFocusNodes[i][j].dispose(); }); }); super.dispose(); } @override Widget build(BuildContext context) { final state = widget.viewModel.state; final viewModel = widget.viewModel; final reportResult = viewModel.reportResult; final reportState = viewModel.reportState; final settings = state.userCompany.settings; final reportSettings = settings != null && settings.reportSettings.containsKey(reportState.report) ? settings.reportSettings[reportState.report] : ReportSettingsEntity(); final sortedColumns = reportResult.sortedColumns(reportState); return Column( children: [ if (reportState.chart.isNotEmpty) ClipRect( child: ReportCharts( viewModel: widget.viewModel, ), ), if (reportResult.showTotals) FormCard( child: isMobile(context) ? SingleChildScrollView( scrollDirection: Axis.horizontal, child: TotalsDataTable( viewModel: viewModel, reportResult: reportResult, reportSettings: reportSettings, ), ) : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TotalsDataTable( viewModel: viewModel, reportResult: reportResult, reportSettings: reportSettings, ), ], ), ), SingleChildScrollView( padding: const EdgeInsets.all(12), child: AppPaginatedDataTable( //showFirstLastButtons: true, subtractOneFromCount: true, header: SizedBox(), sortColumnIndex: sortedColumns.contains(reportSettings.sortColumn) ? sortedColumns.indexOf(reportSettings.sortColumn) : null, sortAscending: reportSettings.sortAscending ?? true, columns: reportResult.tableColumns( context, (index, ascending) => widget.viewModel .onReportSorted(sortedColumns[index], ascending)), source: dataTableSource, ), ) ], ); } } class TotalsDataTable extends StatelessWidget { const TotalsDataTable( {this.reportSettings, this.reportResult, this.viewModel}); final ReportsScreenVM viewModel; final ReportSettingsEntity reportSettings; final ReportResult reportResult; @override Widget build(BuildContext context) { return mt.DataTable( sortColumnIndex: reportSettings.sortTotalsIndex != null && reportResult.columns.length > reportSettings.sortTotalsIndex ? reportSettings.sortTotalsIndex : null, sortAscending: reportSettings.sortTotalsAscending ?? true, columns: reportResult.totalColumns( context, (index, ascending) => viewModel.onReportTotalsSorted(index, ascending)), rows: reportResult.totalRows(context), ); } } enum ReportColumnType { string, dateTime, date, number, bool, age, duration, } bool canTotalColumn(String column) { if (column == 'notification_threshold') { return false; } if (column.contains('_rate')) { return false; } return true; } ReportColumnType getReportColumnType(String column, BuildContext context) { column = toSnakeCase(column); ReportColumnType convertCustomFieldType(String type) { if (type == kFieldTypeDate) { return ReportColumnType.date; } else if (type == kFieldTypeSwitch) { return ReportColumnType.bool; } else { return ReportColumnType.string; } } final store = StoreProvider.of(context); final company = store.state.userCompany.company; if (column.startsWith('surcharge')) { return ReportColumnType.number; } else if (column == 'duration') { return ReportColumnType.duration; } else if (company.hasCustomField(column)) { return convertCustomFieldType(company.getCustomFieldType(column)); } else if (EntityPresenter.isFieldNumeric(column)) { return ReportColumnType.number; } else if (['updated_at', 'created_at', 'start_time', 'end_time'] .contains(column)) { return ReportColumnType.dateTime; } else if ((column.contains('_date') && column != 'paid_to_date') || ['date', 'valid_until'].contains(column)) { return ReportColumnType.date; } else if (column == 'age') { return ReportColumnType.age; } else if (column.startsWith('is_')) { return ReportColumnType.bool; } else { return ReportColumnType.string; } } class ReportDataTableSource extends AppDataTableSource { ReportDataTableSource({ @required this.context, @required this.textEditingControllers, @required this.textEditingFocusNodes, @required this.onFilterChanged, @required this.viewModel, }); ReportsScreenVM viewModel; final BuildContext context; final Map> textEditingControllers; final Map> textEditingFocusNodes; final Function(String, String) onFilterChanged; @override int get selectedRowCount => 0; @override bool get isRowCountApproximate => false; @override int get rowCount { final reportState = viewModel.reportState; if (reportState.group.isEmpty || reportState.isGroupByFiltered) { return viewModel.reportResult.data.length + 1; } else { return viewModel.groupTotals.totals == null ? 1 : viewModel.groupTotals.totals.length + 1; } } @override DataRow getRow(int index) { final reportResult = viewModel.reportResult; if (index == 0) { return reportResult.tableFilters( context, textEditingControllers[viewModel.reportState.report], textEditingFocusNodes[viewModel.reportState.report], (column, value) => onFilterChanged(column, value)); } else { return reportResult.tableRow(context, viewModel, index); } } } class ReportResult { ReportResult({ @required this.columns, @required this.allColumns, @required this.defaultColumns, @required this.data, this.entities, this.showTotals = true, }); final List columns; final List allColumns; final List defaultColumns; final List> data; final List entities; final bool showTotals; static bool matchField({ @required String column, @required dynamic value, @required UserCompanyEntity userCompany, @required ReportsUIState reportsUIState, AppLocalization localization, }) { if (reportsUIState.filters.containsKey(column)) { final filter = reportsUIState.filters[column]; if (filter.isNotEmpty) { if (column == 'age') { final min = kAgeGroups[filter]; if (filter == kAgeGroupPaid) { return value == -1; } else if (filter == kAgeGroup120) { return value > min; } else { final max = min + 30; if (value < min || value >= max) { return false; } } } else if (value.runtimeType == int || value.runtimeType == double) { if (!ReportResult.matchAmount(filter: filter, amount: value)) { return false; } } else if (value.runtimeType == bool || filter == 'true' || filter == 'false') { // Support custom fields String boolFilter = filter; if (filter.toLowerCase() == 'yes') { boolFilter = 'true'; } else if (filter.toLowerCase() == 'no') { boolFilter = 'false'; } if (value.runtimeType == String) { if (value.toLowerCase() == 'yes') { value = 'true'; } else if (value.toLowerCase() == 'no') { value = 'false'; } } if (boolFilter != '$value') { return false; } } else if (value.runtimeType == EntityType) { return filter.toLowerCase() == '$value'.toLowerCase(); } else if (isValidDate(value)) { if (!ReportResult.matchDateTime( filter: filter, value: value, reportsUIState: reportsUIState, userCompany: userCompany)) { return false; } } else if (!ReportResult.matchString(filter: filter, value: value)) { return false; } } } return true; } static bool matchString({String filter, String value}) { filter = filter.trim(); if (filter == null || filter.isEmpty) { return true; } value = (value ?? '').toLowerCase(); filter = (filter ?? '').toLowerCase(); if (filter == 'null' && value.isEmpty) { return true; } if (value.contains(filter)) { return true; } final localization = AppLocalization.of(navigatorKey.currentContext); if (localization.lookup(value).toLowerCase().contains(filter)) { return true; } return false; } static bool matchAmount({String filter, num amount}) { final String range = filter.replaceAll(',', '-') + '-'; final List parts = range.split('-'); final min = parseDouble(parts[0]); final max = parts.length > 1 ? parseDouble(parts[1]) : 0; if (amount < min || (max > 0 && amount > max)) { return false; } return true; } static bool matchDateTime({ String filter, String value, UserCompanyEntity userCompany, ReportsUIState reportsUIState, }) { DateRange dateRange = DateRange.last30Days; try { dateRange = DateRange.valueOf(filter); } catch (exception) { // } final startDate = calculateStartDate( dateRange: dateRange, company: userCompany.company, customStartDate: reportsUIState.customStartDate, customEndDate: reportsUIState.customEndDate, ); final endDate = calculateEndDate( dateRange: dateRange, company: userCompany.company, customStartDate: reportsUIState.customStartDate, customEndDate: reportsUIState.customEndDate, ); final customStartDate = reportsUIState.customStartDate; final customEndDate = reportsUIState.customEndDate; value = value.split('T').first; if (dateRange == DateRange.custom) { if (customStartDate.isNotEmpty && customEndDate.isNotEmpty) { if (!(startDate.compareTo(value) <= 0 && endDate.compareTo(value) >= 0)) { return false; } } else if (customStartDate.isNotEmpty) { if (!(startDate.compareTo(value) <= 0)) { return false; } } else if (customEndDate.isNotEmpty) { if (!(endDate.compareTo(value) >= 0)) { return false; } } } else { if (!(startDate.compareTo(value) <= 0 && endDate.compareTo(value) >= 0)) { return false; } } return true; } List sortedColumns(ReportsUIState reportState) { final data = columns.toList(); final group = reportState.group; if (group.isNotEmpty) { data.remove(group); data.insert(0, group); } return data; } List tableColumns( BuildContext context, Function(int, bool) onSortCallback) { final localization = AppLocalization.of(context); final store = StoreProvider.of(context); final company = store.state.company; final reportState = store.state.uiState.reportsUIState; return [ for (String column in sortedColumns(reportState)) DataColumn( label: Container( constraints: BoxConstraints(minWidth: kTableColumnWidthMin), child: Row( children: [ Text( (company.getCustomFieldLabel(column).isNotEmpty ? company.getCustomFieldLabel(column) : localization.lookup(column)) + ' ', maxLines: 1, overflow: TextOverflow.ellipsis, ), if (column == reportState.group) IconButton( onPressed: () { store.dispatch(UpdateReportSettings( report: reportState.report, group: '', )); }, icon: Icon(Icons.clear, color: Colors.grey)) ], ), ), numeric: getReportColumnType(column, context) == ReportColumnType.number, onSort: onSortCallback, ) ]; } DataRow tableFilters( BuildContext context, Map textEditingControllers, Map textEditingFocusNodes, Function(String, String) onFilterChanged) { final localization = AppLocalization.of(context); final theme = Theme.of(context); final store = StoreProvider.of(context); final reportState = store.state.uiState.reportsUIState; return DataRow(cells: [ for (String column in sortedColumns(reportState)) if (textEditingControllers == null || !textEditingControllers.containsKey(column)) DataCell(Text('')) else if (getReportColumnType(column, context) == ReportColumnType.bool) DataCell(AppDropdownButton( labelText: null, showBlank: true, blankValue: null, value: textEditingControllers[column].text == 'true' ? true : textEditingControllers[column].text == 'false' ? false : null, onChanged: (dynamic value) { if (value == null) { textEditingControllers[column].text = ''; onFilterChanged(column, ''); } else { textEditingControllers[column].text = value.toString(); onFilterChanged(column, value.toString()); } }, items: [ DropdownMenuItem( child: Text(AppLocalization.of(context).yes), value: true, ), DropdownMenuItem( child: Text(AppLocalization.of(context).no), value: false, ), ], )) else if (getReportColumnType(column, context) == ReportColumnType.age) DataCell(AppDropdownButton( value: (textEditingControllers[column].text ?? '').isNotEmpty && textEditingControllers[column].text != 'null' ? textEditingControllers[column].text : null, showBlank: true, onChanged: (dynamic value) { textEditingControllers[column].text = value; onFilterChanged(column, value); }, items: kAgeGroups.keys .map((ageGroup) => DropdownMenuItem( child: Text(localization.lookup(ageGroup)), value: ageGroup, )) .toList(), )) else if ([ ReportColumnType.number, ReportColumnType.duration, ].contains(getReportColumnType(column, context))) DataCell(TextFormField( controller: textEditingControllers[column], keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true), decoration: InputDecoration( suffixIcon: textEditingControllers == null ? null : (textEditingControllers[column]?.text ?? '').isEmpty ? null : IconButton( icon: Icon( Icons.clear, color: Colors.grey, ), onPressed: () { textEditingControllers[column].text = ''; onFilterChanged(column, ''); }, )), )) else if ([ ReportColumnType.date, ReportColumnType.dateTime, ].contains(getReportColumnType(column, context))) DataCell(AppDropdownButton( labelText: null, showBlank: true, blankValue: null, value: (reportState.filters[column] ?? '').isNotEmpty ? DateRange.valueOf(reportState.filters[column]) : null, onChanged: (dynamic value) { if (value == null) { textEditingControllers[column].text = ''; onFilterChanged(column, ''); } else { textEditingControllers[column].text = value.toString(); onFilterChanged(column, value.toString()); } }, items: DateRange.values .map((dateRange) => DropdownMenuItem( child: Text(localization.lookup(dateRange.toString())), value: dateRange, )) .toList(), )) // TODO remove DEMO_MODE check else if (Config.DEMO_MODE) DataCell(TextFormField( controller: textEditingControllers != null ? textEditingControllers[column] : null, decoration: InputDecoration( suffixIcon: textEditingControllers == null ? null : (textEditingControllers[column]?.text ?? '').isEmpty ? null : IconButton( icon: Icon( Icons.clear, color: Colors.grey, ), onPressed: () { textEditingControllers[column].text = ''; onFilterChanged(column, ''); }, )), )) else DataCell( RawAutocomplete( textEditingController: textEditingControllers[column], focusNode: textEditingFocusNodes[column], optionsBuilder: (TextEditingValue textEditingValue) { final filter = textEditingValue.text.toLowerCase(); final index = columns.indexOf(column); final options = data .where((row) => row[index] .renderText(context, column) .toLowerCase() .contains(filter) && row[index] .renderText(context, column) .trim() .isNotEmpty) .map((row) => row[index].renderText(context, column)) .toSet() .toList(); return options; }, onSelected: (value) { final textEditingController = textEditingControllers[column]; textEditingController.text = value; onFilterChanged(column, value); textEditingFocusNodes[column].requestFocus(); WidgetsBinding.instance.addPostFrameCallback((duration) { textEditingController.selection = TextSelection.fromPosition( TextPosition(offset: textEditingController.text.length)); }); }, fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) { return DecoratedFormField( keyboardType: TextInputType.text, decoration: textEditingController.text.isEmpty ? null : InputDecoration( suffixIcon: IconButton( icon: Icon( Icons.clear, color: Colors.grey, ), onPressed: () { textEditingControllers[column].text = ''; onFilterChanged(column, ''); textEditingFocusNodes[column].unfocus(); }, )), controller: textEditingController, focusNode: focusNode, onFieldSubmitted: (String value) { onFieldSubmitted(); }, ); }, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { final highlightedIndex = AutocompleteHighlightedOption.of(context); return Theme( data: theme, child: Align( alignment: Alignment.topLeft, child: Material( elevation: 4, child: AppBorder( child: Container( color: Theme.of(context).cardColor, width: 250, constraints: BoxConstraints(maxHeight: 270), child: ScrollableListViewBuilder( itemCount: options.length, itemBuilder: (BuildContext context, int index) { return Container( color: highlightedIndex == index ? convertHexStringToColor( store.state.prefState.enableDarkMode ? kDefaultDarkSelectedColor : kDefaultLightSelectedColor) : Theme.of(context).cardColor, child: ListTile( title: Text(options.elementAt(index), style: Theme.of(context) .textTheme .subtitle1), onTap: () => onSelected( options.elementAt(index), ), ), ); }, ), ), ), ), ), ); }, ), ), ]); } DataRow tableRow(BuildContext context, ReportsScreenVM viewModel, int index) { final store = StoreProvider.of(context); final state = store.state; final reportState = state.uiState.reportsUIState; final groupBy = reportState.group; final sorted = sortedColumns(reportState); if (groupBy.isEmpty || reportState.isGroupByFiltered) { final row = data[index - 1]; final cells = []; for (var j = 0; j < row.length; j++) { final index = columns.indexOf(sorted[j]); final cell = row[index]; final column = sorted[j]; cells.add( DataCell( ConstrainedBox( constraints: BoxConstraints(maxWidth: kTableColumnWidthMax), child: cell.renderWidget(context, column), ), onTap: () { viewEntityById( entityId: cell.entityId, entityType: cell.entityType); }, ), ); } return DataRow(cells: cells); } else { final groupTotals = viewModel.groupTotals; final group = groupTotals.rows[index - 1]; final values = viewModel.groupTotals.totals[group]; final cells = []; final localization = AppLocalization.of(context); for (var column in sortedColumns(reportState)) { String value = ''; final columnType = getReportColumnType(column, context); if (column == groupBy) { if (group.isEmpty) { value = localization.blank; } else if (columnType == ReportColumnType.dateTime || columnType == ReportColumnType.date) { value = formatDate(group, context); } else if (columnType == ReportColumnType.age || EntityPresenter.isFieldLocalized(column)) { value = localization.lookup(group); } else { value = group == 'null' ? localization.blank : group; } value = value + ' (' + values['count'].floor().toString() + ')'; } else if (columnType == ReportColumnType.number) { final currencyId = values['${column}_currency_id']; value = formatNumber(values[column], context, formatNumberType: column.toLowerCase().contains('quantity') ? FormatNumberType.double : FormatNumberType.money, currencyId: currencyId == null ? null : currencyId.round().toString()); } else if (columnType == ReportColumnType.duration) { value = formatDuration(Duration(seconds: values[column].toInt())); } cells.add(DataCell(Text(value), onTap: () { if (group.isEmpty) { return; } if (column == groupBy) { String filter = group; String customStartDate = ''; String customEndDate = ''; if (getReportColumnType(column, context) == ReportColumnType.dateTime || getReportColumnType(column, context) == ReportColumnType.date) { filter = DateRange.custom.toString(); final date = DateTime.tryParse(group); customStartDate = group; if (reportState.subgroup == kReportGroupDay) { customEndDate = convertDateTimeToSqlDate(date); } else if (reportState.subgroup == kReportGroupMonth) { customEndDate = convertDateTimeToSqlDate(addDays(addMonths(date, 1), -1)); } else if (reportState.subgroup == kReportGroupWeek) { customEndDate = convertDateTimeToSqlDate(addDays(date, 6)); } else { customEndDate = convertDateTimeToSqlDate(addDays(addYears(date, 1), -1)); } } else if (getReportColumnType(column, context) == ReportColumnType.bool) { filter = filter == localization.yes ? 'true' : filter == localization.no ? 'false' : ''; } store.dispatch( UpdateReportSettings( report: reportState.report, selectedGroup: filter, customStartDate: customStartDate, customEndDate: customEndDate, filters: reportState.filters .rebuild((b) => b..addAll({column: filter})), ), ); } })); } return DataRow(cells: cells); } } List totalColumns( BuildContext context, Function(int, bool) onSortCallback) { final store = StoreProvider.of(context); final company = store.state.company; final localization = AppLocalization.of(context); final sortedColumns = columns .where((column) => canTotalColumn(column)) .toList() ..sort((String str1, String str2) => str1.compareTo(str2)); //for (String column in sortedColumns) // print('## $column => ${getReportColumnType(column, context)}'); final totalColumns = [ mt.DataColumn( label: Text(localization.currency), onSort: onSortCallback, ), mt.DataColumn( label: Text(localization.count), onSort: onSortCallback, ), for (String column in sortedColumns) if ([ ReportColumnType.number, ReportColumnType.age, ReportColumnType.duration, ].contains(getReportColumnType(column, context))) mt.DataColumn( label: Text( company.getCustomFieldLabel(column).isEmpty ? localization.lookup(column) : company.getCustomFieldLabel(column), overflow: TextOverflow.ellipsis, ), numeric: true, onSort: onSortCallback, ) ]; //print('## Total Columns: ${totalColumns.length}'); return totalColumns; } List totalRows(BuildContext context) { final rows = []; final store = StoreProvider.of(context); final state = store.state; final reportState = state.uiState.reportsUIState; final settings = state.userCompany.settings; final reportSettings = settings != null && settings.reportSettings.containsKey(reportState.report) ? settings.reportSettings[reportState.report] : ReportSettingsEntity(); final Map> totals = {}; final allColumns = []; for (var i = 0; i < data.length; i++) { final row = data[i]; bool countedRow = false; for (var j = 0; j < row.length; j++) { final cell = row[j]; final column = columns[j]; final canTotal = (cell is ReportNumberValue || cell is ReportDurationValue || cell is ReportAgeValue) && canTotalColumn(column); String currencyId = ''; if (canTotal) { if (!allColumns.contains(column)) { allColumns.add(column); } if (cell is ReportNumberValue) { currencyId = cell.currencyId ?? ''; } else if (cell is ReportAgeValue) { currencyId = cell.currencyId ?? ''; } else if (cell is ReportDurationValue) { currencyId = cell.currencyId ?? ''; } // if the specific cell doesn't have a currency // set then find it in the row if (currencyId.isEmpty) { for (var k = 0; k < row.length; k++) { final cell = row[k]; if (cell is ReportNumberValue) { currencyId = cell.currencyId ?? ''; } else if (cell is ReportAgeValue) { currencyId = cell.currencyId ?? ''; } else if (cell is ReportDurationValue) { currencyId = cell.currencyId ?? ''; } } } if (!totals.containsKey(currencyId)) { totals[currencyId] = {'count': 0}; } if (!countedRow) { totals[currencyId]['count']++; countedRow = true; } if (!totals[currencyId].containsKey(column)) { totals[currencyId][column] = 0; } totals[currencyId][column] += cell.doubleValue; } } } for (var currencyId in totals.keys) { for (var column in allColumns) { if (!totals[currencyId].containsKey(column)) { totals[currencyId][column] = 0; } } } final keys = totals.keys.where((element) => element != null).toList(); if (reportSettings.sortTotalsIndex != null) { keys.sort((rowA, rowB) { dynamic valueA; dynamic valueB; if (reportSettings.sortTotalsIndex == 0) { final currencyMap = state.staticState.currencyMap; valueA = currencyMap[rowA]?.listDisplayName; valueB = currencyMap[rowB]?.listDisplayName; } else if (reportSettings.sortTotalsIndex == 1) { valueA = totals[rowA]['count']; valueB = totals[rowB]['count']; } else { final List fields = totals[rowA].keys.toList() ..remove('count') ..sort((String str1, String str2) => str1.compareTo(str2)); final sortColumn = fields[reportSettings.sortTotalsIndex - 2]; valueA = totals[rowA][sortColumn]; valueB = totals[rowB][sortColumn]; } if (valueA == null || valueB == null) { return 0; } return reportSettings.sortTotalsAscending ? valueA.compareTo(valueB) : valueB.compareTo(valueA); }); } List allFields = []; keys.forEach((currencyId) { final values = totals[currencyId]; allFields.addAll(values.keys); }); allFields = allFields.toSet().toList() ..sort((String str1, String str2) => str1.compareTo(str2)); keys.forEach((currencyId) { final values = totals[currencyId]; final cells = [ mt.DataCell(Text( store.state.staticState.currencyMap[currencyId]?.listDisplayName ?? '')), mt.DataCell(Text(values['count'].toInt().toString())), ]; allFields.forEach((field) { final amount = values[field]; if (field != 'count') { String value; if (field == 'age') { value = formatNumber(amount / values['count'], context, formatNumberType: FormatNumberType.double); } else if (field == 'duration') { value = formatDuration(Duration(seconds: amount.toInt())); } else { value = formatNumber(amount, context, currencyId: currencyId, formatNumberType: EntityPresenter.isFieldAmount(field) ? FormatNumberType.double : FormatNumberType.money); } cells.add(mt.DataCell(Text(value))); } }); //print('## Total Rows: ${cells.length}'); rows.add(mt.DataRow(cells: cells)); }); return rows; } } abstract class ReportElement { ReportElement({this.entityType, this.entityId}); final EntityType entityType; final String entityId; double get doubleValue => 0; String get stringValue => ''; Widget renderWidget(BuildContext context, String column) { throw 'Error: need to override renderWidget()'; } String renderText(BuildContext context, String column) { throw 'Error: need to override sortString()'; } } class ReportStringValue extends ReportElement { ReportStringValue({ @required this.value, @required EntityType entityType, @required String entityId, }) : super(entityType: entityType, entityId: entityId); final String value; @override String get stringValue => value; @override Widget renderWidget(BuildContext context, String column) { return Text( renderText(context, column), maxLines: 2, overflow: TextOverflow.ellipsis, ); } @override String renderText(BuildContext context, String column) { if (getReportColumnType(column, context) == ReportColumnType.dateTime || getReportColumnType(column, context) == ReportColumnType.date) { return formatDate(value, context, showTime: getReportColumnType(column, context) == ReportColumnType.dateTime); } else if (EntityPresenter.isFieldLocalized(column)) { return AppLocalization.of(context).lookup(value); } else { return value ?? ''; } } } class ReportEntityTypeValue extends ReportElement { ReportEntityTypeValue({ @required this.value, @required EntityType entityType, @required String entityId, }) : super(entityType: entityType, entityId: entityId); final EntityType value; @override String get stringValue => '$value'; @override Widget renderWidget(BuildContext context, String column) { return Text(renderText(context, column)); } @override String renderText(BuildContext context, String column) { return AppLocalization.of(context).lookup('$value'); } } class ReportAgeValue extends ReportElement { ReportAgeValue({ @required this.value, @required EntityType entityType, @required String entityId, @required this.currencyId, }) : super(entityType: entityType, entityId: entityId); final int value; final String currencyId; @override String get stringValue => '$value'; @override double get doubleValue => value == -1 ? 0 : value.toDouble(); @override Widget renderWidget(BuildContext context, String column) { return Text(renderText(context, column)); } @override String renderText(BuildContext context, String column) { return value == -1 ? AppLocalization.of(context).paid : '$value'; } } class ReportDurationValue extends ReportElement { ReportDurationValue({ @required this.value, @required EntityType entityType, @required String entityId, @required this.currencyId, }) : super(entityType: entityType, entityId: entityId); final int value; final String currencyId; @override String get stringValue => '$value'; @override double get doubleValue => value.toDouble(); @override Widget renderWidget(BuildContext context, String column) { return Text(renderText(context, column)); } @override String renderText(BuildContext context, String column) { return formatDuration(Duration(seconds: value)); } } class ReportIntValue extends ReportElement { ReportIntValue({ this.value, EntityType entityType, String entityId, }) : super(entityType: entityType, entityId: entityId); final int value; @override String get stringValue => '$value'; @override double get doubleValue => value.toDouble(); @override Widget renderWidget(BuildContext context, String column) { return Text(renderText(context, column)); } @override String renderText(BuildContext context, String column) { return formatNumber(value.toDouble(), context, formatNumberType: FormatNumberType.int); } } class ReportNumberValue extends ReportElement { ReportNumberValue({ @required this.value, @required EntityType entityType, @required String entityId, @required this.currencyId, @required this.exchangeRate, this.formatNumberType = FormatNumberType.money, }) : super(entityType: entityType, entityId: entityId); final double value; final FormatNumberType formatNumberType; final String currencyId; final double exchangeRate; @override double get doubleValue => value; @override String get stringValue => '$value'; @override Widget renderWidget(BuildContext context, String column) { return Text(renderText(context, column)); } @override String renderText(BuildContext context, String column) { if (currencyId == null || column.endsWith('_rate') || column.endsWith('_rate1') || column.endsWith('_rate2') || column.endsWith('_rate3')) { return formatNumber(value, context, formatNumberType: FormatNumberType.double); } return formatNumber(value, context, currencyId: currencyId, formatNumberType: formatNumberType ?? FormatNumberType.money); } } class ReportBoolValue extends ReportElement { ReportBoolValue({ @required this.value, @required EntityType entityType, @required String entityId, }) : super(entityType: entityType, entityId: entityId); final bool value; @override String get stringValue => '$value'; @override Widget renderWidget(BuildContext context, String column) { final localization = AppLocalization.of(context); return SizedBox( width: 80, child: Text( value == true ? localization.yes : localization.no, textAlign: TextAlign.center, ), ); } @override String renderText(BuildContext context, String column) { final localization = AppLocalization.of(context); return value == true ? localization.yes : localization.no; } } int sortReportTableRows(dynamic rowA, dynamic rowB, ReportSettingsEntity reportSettings, List columns) { if (reportSettings.sortColumn == null || reportSettings.sortColumn.isEmpty) { return 0; } final index = columns.indexOf(reportSettings.sortColumn); if (index == -1) { return 0; } else if (rowA.length <= index || rowB.length <= index) { return 0; } final dynamic valueA = rowA[index].value; final dynamic valueB = rowB[index].value; if (reportSettings.sortAscending) { return valueA.compareTo(valueB); } else { return valueB.compareTo(valueA); } }