diff --git a/lib/constants.dart b/lib/constants.dart index b86ac874438..1831784fc43 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -4,7 +4,7 @@ class Constants { } // TODO remove version once #46609 is fixed -const String kClientVersion = '5.0.83'; +const String kClientVersion = '5.0.84'; const String kMinServerVersion = '5.0.4'; const String kAppName = 'Invoice Ninja'; @@ -555,6 +555,7 @@ const String kDefaultLightSelectedColor = '#e5f5ff'; const String kDefaultLightBorderColor = '#dfdfdf'; const String kReportGroupDay = 'day'; +const String kReportGroupWeek = 'week'; const String kReportGroupMonth = 'month'; const String kReportGroupYear = 'year'; diff --git a/lib/data/models/expense_model.dart b/lib/data/models/expense_model.dart index c87a010901e..b3de5152ff7 100644 --- a/lib/data/models/expense_model.dart +++ b/lib/data/models/expense_model.dart @@ -352,6 +352,9 @@ abstract class ExpenseEntity extends Object shouldBeInvoiced && userCompany.canCreate(EntityType.invoice)) { actions.add(EntityAction.invoiceExpense); + if ((clientId ?? '').isNotEmpty) { + actions.add(EntityAction.addToInvoice); + } } } diff --git a/lib/data/models/models.dart b/lib/data/models/models.dart index 310744fb6dd..93b2d27ff7f 100644 --- a/lib/data/models/models.dart +++ b/lib/data/models/models.dart @@ -109,6 +109,7 @@ class EntityAction extends EnumClass { static const EntityAction disconnect = _$disconnect; static const EntityAction viewInvoice = _$viewInvoice; static const EntityAction changeStatus = _$changeStatus; + static const EntityAction addToInvoice = _$addToInvoice; @override String toString() { diff --git a/lib/data/models/models.g.dart b/lib/data/models/models.g.dart index 9e5f784a3c6..33af0227035 100644 --- a/lib/data/models/models.g.dart +++ b/lib/data/models/models.g.dart @@ -75,6 +75,7 @@ const EntityAction _$resendInvite = const EntityAction._('resendInvite'); const EntityAction _$disconnect = const EntityAction._('disconnect'); const EntityAction _$viewInvoice = const EntityAction._('viewInvoice'); const EntityAction _$changeStatus = const EntityAction._('changeStatus'); +const EntityAction _$addToInvoice = const EntityAction._('addToInvoice'); EntityAction _$valueOf(String name) { switch (name) { @@ -202,6 +203,8 @@ EntityAction _$valueOf(String name) { return _$viewInvoice; case 'changeStatus': return _$changeStatus; + case 'addToInvoice': + return _$addToInvoice; default: throw new ArgumentError(name); } @@ -271,6 +274,7 @@ final BuiltSet _$values = _$disconnect, _$viewInvoice, _$changeStatus, + _$addToInvoice, ]); Serializer _$entityActionSerializer = diff --git a/lib/data/models/task_model.dart b/lib/data/models/task_model.dart index c7687555f5b..f245a0085d0 100644 --- a/lib/data/models/task_model.dart +++ b/lib/data/models/task_model.dart @@ -608,6 +608,9 @@ abstract class TaskEntity extends Object if (!isInvoiced && !isRunning) { if (userCompany.canCreate(EntityType.invoice)) { actions.add(EntityAction.invoiceTask); + if ((clientId ?? '').isNotEmpty) { + actions.add(EntityAction.addToInvoice); + } } } } diff --git a/lib/redux/dashboard/dashboard_selectors.dart b/lib/redux/dashboard/dashboard_selectors.dart index 78bfc795c45..5af021ea376 100644 --- a/lib/redux/dashboard/dashboard_selectors.dart +++ b/lib/redux/dashboard/dashboard_selectors.dart @@ -807,3 +807,22 @@ var memoizedPreviousChartExpenses = memo5( BuiltMap invoiceMap, BuiltMap expenseMap) => chartExpenses(currencyMap, company, settings, invoiceMap, expenseMap)); + +var memoizedRunningTasks = memo2( + (BuiltMap taskMap, String userId) => + runningTasks(taskMap, userId)); + +List runningTasks( + BuiltMap taskMap, String userId) { + final tasks = []; + + taskMap.forEach((taskId, task) { + if (task.isRunning && + !task.isDeleted && + (task.createdUserId == userId || task.assignedUserId == userId)) { + tasks.add(task); + } + }); + + return tasks; +} diff --git a/lib/redux/expense/expense_actions.dart b/lib/redux/expense/expense_actions.dart index a067954d559..aab02075528 100644 --- a/lib/redux/expense/expense_actions.dart +++ b/lib/redux/expense/expense_actions.dart @@ -18,6 +18,7 @@ import 'package:invoiceninja_flutter/redux/document/document_actions.dart'; import 'package:invoiceninja_flutter/redux/expense/expense_selectors.dart'; import 'package:invoiceninja_flutter/ui/app/entities/entity_actions_dialog.dart'; import 'package:invoiceninja_flutter/utils/completers.dart'; +import 'package:invoiceninja_flutter/utils/dialogs.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; class ViewExpenseList implements PersistUI { @@ -277,6 +278,7 @@ void handleExpenseAction( ); break; case EntityAction.invoiceExpense: + case EntityAction.addToInvoice: final availableExpenses = expenses.where((entity) { final expense = entity as ExpenseEntity; return !expense.isDeleted && !expense.isInvoiced; @@ -304,15 +306,23 @@ void handleExpenseAction( )) .toList(); if (items.isNotEmpty) { - createEntity( - context: context, - entity: InvoiceEntity(state: state, client: client).rebuild( - (b) => b - ..lineItems.addAll(items) - ..projectId = projectId - ..vendorId = vendorId, - ), - ); + if (action == EntityAction.invoiceExpense) { + createEntity( + context: context, + entity: InvoiceEntity(state: state, client: client).rebuild( + (b) => b + ..lineItems.addAll(items) + ..projectId = projectId + ..vendorId = vendorId, + ), + ); + } else { + addToInvoiceDialog( + context: context, + clientId: expense.clientId, + items: items, + ); + } } break; case EntityAction.restore: diff --git a/lib/redux/task/task_actions.dart b/lib/redux/task/task_actions.dart index 96ef2b4be5e..5af3b8821e4 100644 --- a/lib/redux/task/task_actions.dart +++ b/lib/redux/task/task_actions.dart @@ -382,6 +382,7 @@ void handleTaskAction( StopTasksRequest(snackBarCompleter(context, message), taskIds)); break; case EntityAction.invoiceTask: + case EntityAction.addToInvoice: tasks.sort((taskA, taskB) { final taskAEntity = taskA as TaskEntity; final taskBEntity = taskB as TaskEntity; @@ -435,11 +436,20 @@ void handleTaskAction( }); if (items.isNotEmpty) { - createEntity( + if (action == EntityAction.invoiceTask) { + createEntity( + context: context, + entity: + InvoiceEntity(state: state, client: client).rebuild((b) => b + ..lineItems.addAll(items) + ..projectId = projectId)); + } else { + addToInvoiceDialog( context: context, - entity: InvoiceEntity(state: state, client: client).rebuild((b) => b - ..lineItems.addAll(items) - ..projectId = projectId)); + clientId: task.clientId, + items: items, + ); + } } break; case EntityAction.clone: diff --git a/lib/ui/app/tables/entity_datatable.dart b/lib/ui/app/tables/entity_datatable.dart index 0d7ec911d84..cf6421bf999 100644 --- a/lib/ui/app/tables/entity_datatable.dart +++ b/lib/ui/app/tables/entity_datatable.dart @@ -147,6 +147,9 @@ class EntityDataTableSource extends AppDataTableSource { maxWidth: wideFields.contains(field) ? kTableColumnWidthMax * 1.5 : kTableColumnWidthMax, + minWidth: field == ProductFields.description + ? kTableColumnWidthMax + : 0, ), ), onTap: () => onTap(entity), diff --git a/lib/ui/dashboard/dashboard_panels.dart b/lib/ui/dashboard/dashboard_panels.dart index dcc5a77dd5b..fea39b19415 100644 --- a/lib/ui/dashboard/dashboard_panels.dart +++ b/lib/ui/dashboard/dashboard_panels.dart @@ -7,6 +7,11 @@ import 'package:flutter/material.dart'; // Package imports: import 'package:charts_common/common.dart'; import 'package:charts_flutter/flutter.dart' as charts; +import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; +import 'package:invoiceninja_flutter/redux/task/task_actions.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/live_text.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; // Project imports: @@ -407,7 +412,62 @@ class DashboardPanels extends StatelessWidget { return LoadingIndicator(); } + final runningTasks = + memoizedRunningTasks(state.taskState.map, state.user.id); + + Widget _runningTasks() { + return Padding( + padding: const EdgeInsets.only(top: 20, left: 12), + child: Wrap( + spacing: 8, + children: runningTasks.map((task) { + final client = state.clientState.map[task.clientId]; + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kBorderRadius), + ), + child: AppBorder( + hideBorder: !isDarkMode(context), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 180), + child: Tooltip( + message: task.description, + child: ListTile( + dense: true, + title: LiveText(() { + return formatDuration(task.calculateDuration()); + }), + subtitle: Text( + client != null ? client.displayName : task.number, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => + viewEntity(entity: task, filterEntity: client), + onLongPress: () => + editEntity(context: context, entity: task), + leading: ActionMenuButton( + entity: task, + entityActions: task.getActions( + includeEdit: true, + userCompany: state.userCompany, + ), + onSelected: (context, action) => + handleTaskAction(context, [task], action), + ), + ), + ), + ), + ), + ); + }).toList()), + ); + } + final entityTypes = [ + if (company.isModuleEnabled(EntityType.task) && runningTasks.isNotEmpty) + EntityType.taskStatus, if (company.isModuleEnabled(EntityType.invoice)) EntityType.invoice, if (company.isModuleEnabled(EntityType.invoice)) EntityType.payment, if (company.isModuleEnabled(EntityType.quote)) EntityType.quote, @@ -456,6 +516,8 @@ class DashboardPanels extends StatelessWidget { context: context, onDateSelected: (entityIds) => viewModel .onSelectionChanged(EntityType.expense, entityIds)); + case EntityType.taskStatus: + return _runningTasks(); } return SizedBox(); diff --git a/lib/ui/expense/edit/expense_edit_details.dart b/lib/ui/expense/edit/expense_edit_details.dart index 035bbfd6b7a..77db827e434 100644 --- a/lib/ui/expense/edit/expense_edit_details.dart +++ b/lib/ui/expense/edit/expense_edit_details.dart @@ -192,7 +192,7 @@ class ExpenseEditDetailsState extends State { projectId: expense.projectId, clientId: expense.clientId, onChanged: (selectedId) { - final project = state.projectState.get(selectedId); + final project = store.state.projectState.get(selectedId); viewModel.onChanged(expense.rebuild((b) => b ..projectId = project?.id ..clientId = (project?.clientId ?? '').isNotEmpty diff --git a/lib/ui/invoice/edit/invoice_edit_desktop.dart b/lib/ui/invoice/edit/invoice_edit_desktop.dart index a3f66c776d0..9478552354b 100644 --- a/lib/ui/invoice/edit/invoice_edit_desktop.dart +++ b/lib/ui/invoice/edit/invoice_edit_desktop.dart @@ -948,6 +948,7 @@ class __PdfPreviewState extends State<_PdfPreview> { String _pdfString; http.Response _response; bool _isLoading = false; + bool _pendingLoad = false; @override void didChangeDependencies() { @@ -976,7 +977,12 @@ class __PdfPreviewState extends State<_PdfPreview> { } void _loadPdf() async { - if (!widget.invoice.hasClient || _isLoading) { + if (!widget.invoice.hasClient) { + return; + } + + if (_isLoading) { + _pendingLoad = true; return; } @@ -1017,6 +1023,11 @@ class __PdfPreviewState extends State<_PdfPreview> { 'data:application/pdf;base64,' + base64Encode(response.bodyBytes); WebUtils.registerWebView(_pdfString); } + + if (_pendingLoad) { + _pendingLoad = false; + _loadPdf(); + } }); }).catchError((dynamic error) { setState(() { @@ -1039,7 +1050,7 @@ class __PdfPreviewState extends State<_PdfPreview> { Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (_pageCount > 1 && state.prefState.enableJSPDF) + if (_pageCount > 1 && (state.prefState.enableJSPDF || !kIsWeb)) Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( @@ -1073,7 +1084,9 @@ class __PdfPreviewState extends State<_PdfPreview> { ), Expanded( child: _response == null - ? SizedBox() + ? Container( + color: Colors.grey.shade300, + ) : state.prefState.enableJSPDF || !kIsWeb ? PdfPreview( build: (format) => _response.bodyBytes, @@ -1088,11 +1101,8 @@ class __PdfPreviewState extends State<_PdfPreview> { ], ), if (_isLoading) - Container( - color: Colors.grey.shade300, - child: Center( - child: CircularProgressIndicator(), - ), + Center( + child: CircularProgressIndicator(), ) ], ), diff --git a/lib/ui/reports/reports_screen.dart b/lib/ui/reports/reports_screen.dart index c9279f27295..7935bc2f444 100644 --- a/lib/ui/reports/reports_screen.dart +++ b/lib/ui/reports/reports_screen.dart @@ -60,21 +60,7 @@ class ReportsScreen extends StatelessWidget { final reportResult = viewModel.reportResult; Widget leading = SizedBox(); - - if (state.isHosted && !state.isProPlan && !state.isTrial) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Material(child: HelpText(localization.upgradeToViewReports)), - AppButton( - label: localization.upgrade.toUpperCase(), - onPressed: () => launch(state.userCompany.ninjaPortalUrl), - ) - ], - ), - ); - } + final hideReports = state.isHosted && !state.isProPlan && !state.isTrial; if (isMobile(context) || state.prefState.isMenuFloated) { leading = Builder( @@ -177,6 +163,10 @@ class ReportsScreen extends StatelessWidget { child: Text(localization.day), value: kReportGroupDay, ), + DropdownMenuItem( + child: Text(localization.week), + value: kReportGroupWeek, + ), DropdownMenuItem( child: Text(localization.month), value: kReportGroupMonth, @@ -337,160 +327,180 @@ class ReportsScreen extends StatelessWidget { ), ], ), - actions: [ - 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) { - confirmCallback( - context: context, - message: localization.lookup(action.toString()) + - ' • ' + - (reportResult.entities.length == 1 - ? '1 ${localization.lookup(firstEntity.entityType.toString())}' - : '${reportResult.entities.length} ${localization.lookup(firstEntity.entityType.plural)}'), - callback: (_) { - handleEntitiesActions(reportResult.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: localization.history, - onPressed: () { - if (isMobile(context) || state.prefState.isHistoryFloated) { - Scaffold.of(context).openEndDrawer(); - } else { - store.dispatch( - UpdateUserPreferences(sidebar: AppSidebar.history)); - } - }, - ), - ), - ], - ), - body: ScrollableListView( - 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: [ + actions: hideReports + ? [] + : [ + if (isDesktop(context)) ...[ 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, - ); - }, - ), + 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, + ); + }, ); }), - SizedBox(width: kGutterWidth), - Expanded( - child: AppButton( - label: localization.export, + 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) { + confirmCallback( + context: context, + message: localization.lookup(action.toString()) + + ' • ' + + (reportResult.entities.length == 1 + ? '1 ${localization.lookup(firstEntity.entityType.toString())}' + : '${reportResult.entities.length} ${localization.lookup(firstEntity.entityType.plural)}'), + callback: (_) { + handleEntitiesActions( + reportResult.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: localization.history, onPressed: () { - viewModel.onExportPressed(context); + 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: () => launch(state.userCompany.ninjaPortalUrl), + ) ], ), + ) + : ScrollableListView( + 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, + ) + ], ), - ReportDataTable( - key: ValueKey( - '${viewModel.state.isSaving}_${reportsState.group}_${reportsState.selectedGroup}'), - viewModel: viewModel, - ) - ], - ), ), ); } @@ -639,6 +649,7 @@ class _ReportDataTableState extends State { SingleChildScrollView( padding: const EdgeInsets.all(12), child: PaginatedDataTable( + showFirstLastButtons: true, header: SizedBox(), sortColumnIndex: sortedColumns.contains(reportSettings.sortColumn) ? sortedColumns.indexOf(reportSettings.sortColumn) @@ -1324,6 +1335,8 @@ class ReportResult { } 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)); diff --git a/lib/ui/reports/reports_screen_vm.dart b/lib/ui/reports/reports_screen_vm.dart index 4fee61f3b55..82767e47169 100644 --- a/lib/ui/reports/reports_screen_vm.dart +++ b/lib/ui/reports/reports_screen_vm.dart @@ -511,49 +511,57 @@ GroupTotals calculateReportTotals({ for (var i = 0; i < data.length; i++) { final row = data[i]; - for (var j = 0; j < row.length; j++) { - final cell = row[j]; - final column = columns[j]; - final columnIndex = columns.indexOf(reportState.group); + final columnIndex = columns.indexOf(reportState.group); - if (columnIndex == -1) { - print('## ERROR: colum not found - ${reportState.group}'); - continue; - } + if (columnIndex == -1) { + print('## ERROR: colum not found - ${reportState.group}'); + continue; + } - final groupCell = row[columnIndex]; - String group = groupCell.stringValue; - - if (groupCell is ReportAgeValue) { - final age = groupCell.doubleValue; - if (groupCell.value == -1) { - group = kAgeGroupPaid; - } else if (age < 30) { - group = kAgeGroup0; - } else if (age < 60) { - group = kAgeGroup30; - } else if (age < 90) { - group = kAgeGroup60; - } else if (age < 120) { - group = kAgeGroup90; - } else { - group = kAgeGroup120; - } - } else if (group.isNotEmpty && isValidDate(group)) { - group = convertDateTimeToSqlDate(DateTime.tryParse(group)); - if (reportState.subgroup == kReportGroupYear) { - group = group.substring(0, 4) + '-01-01'; - } else if (reportState.subgroup == kReportGroupMonth) { - group = group.substring(0, 7) + '-01'; - } + final groupCell = row[columnIndex]; + String group = groupCell.stringValue; + + if (groupCell is ReportAgeValue) { + final age = groupCell.doubleValue; + if (groupCell.value == -1) { + group = kAgeGroupPaid; + } else if (age < 30) { + group = kAgeGroup0; + } else if (age < 60) { + group = kAgeGroup30; + } else if (age < 90) { + group = kAgeGroup60; + } else if (age < 120) { + group = kAgeGroup90; + } else { + group = kAgeGroup120; } - - if (!totals.containsKey(group)) { - totals[group] = {'count': 0}; + } else if (group.isNotEmpty && isValidDate(group)) { + group = convertDateTimeToSqlDate(DateTime.tryParse(group)); + if (reportState.subgroup == kReportGroupYear) { + group = group.substring(0, 4) + '-01-01'; + } else if (reportState.subgroup == kReportGroupMonth) { + group = group.substring(0, 7) + '-01'; + } else if (reportState.subgroup == kReportGroupWeek) { + final date = DateTime.parse(group); + final dateWeek = + DateTime(date.year, date.month, date.day - date.weekday % 7); + group = convertDateTimeToSqlDate(dateWeek); } + } + + if (!totals.containsKey(group)) { + totals[group] = {'count': 0}; + } + + for (var j = 0; j < row.length; j++) { + final cell = row[j]; + final column = columns[j]; + if (column == reportState.group) { totals[group]['count'] += 1; } + if (cell is ReportNumberValue || cell is ReportAgeValue || cell is ReportDurationValue) { diff --git a/lib/ui/task/edit/task_edit_desktop.dart b/lib/ui/task/edit/task_edit_desktop.dart index f546aaea97a..2d5a597e23e 100644 --- a/lib/ui/task/edit/task_edit_desktop.dart +++ b/lib/ui/task/edit/task_edit_desktop.dart @@ -1,9 +1,12 @@ // Flutter imports: import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; // Project imports: import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/main_app.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/redux/client/client_selectors.dart'; import 'package:invoiceninja_flutter/redux/task/task_selectors.dart'; import 'package:invoiceninja_flutter/redux/task_status/task_status_selectors.dart'; @@ -114,6 +117,7 @@ class _TaskEditDesktopState extends State { @override Widget build(BuildContext context) { + final store = StoreProvider.of(navigatorKey.currentContext); final viewModel = widget.viewModel; final localization = AppLocalization.of(context); final task = viewModel.task; @@ -188,7 +192,8 @@ class _TaskEditDesktopState extends State { projectId: task.projectId, clientId: task.clientId, onChanged: (selectedId) { - final project = state.projectState.get(selectedId); + final project = + store.state.projectState.get(selectedId); viewModel.onChanged(task.rebuild((b) => b ..projectId = project?.id ..clientId = (project?.clientId ?? '').isNotEmpty diff --git a/lib/ui/task/edit/task_edit_details.dart b/lib/ui/task/edit/task_edit_details.dart index a95fb37f382..17c1ec5cd8f 100644 --- a/lib/ui/task/edit/task_edit_details.dart +++ b/lib/ui/task/edit/task_edit_details.dart @@ -1,8 +1,11 @@ // Flutter imports: import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; // Project imports: import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/main_app.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/redux/client/client_selectors.dart'; import 'package:invoiceninja_flutter/redux/task/task_selectors.dart'; import 'package:invoiceninja_flutter/redux/task_status/task_status_selectors.dart'; @@ -100,6 +103,7 @@ class _TaskEditDetailsState extends State { @override Widget build(BuildContext context) { + final store = StoreProvider.of(navigatorKey.currentContext); final viewModel = widget.viewModel; final localization = AppLocalization.of(context); final task = viewModel.task; @@ -151,7 +155,7 @@ class _TaskEditDetailsState extends State { projectId: task.projectId, clientId: task.clientId, onChanged: (selectedId) { - final project = state.projectState.get(selectedId); + final project = store.state.projectState.get(selectedId); viewModel.onChanged(task.rebuild((b) => b ..projectId = project?.id ..clientId = (project?.clientId ?? '').isNotEmpty diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 513ce18878a..a346f10e9c3 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:invoiceninja_flutter/redux/task/task_actions.dart'; import 'package:invoiceninja_flutter/redux/task_status/task_status_selectors.dart'; +import 'package:invoiceninja_flutter/utils/formatting.dart'; import 'package:invoiceninja_flutter/utils/platforms.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; @@ -508,3 +509,53 @@ void changeTaskStatusDialog({ ); }); } + +void addToInvoiceDialog({ + @required BuildContext context, + @required String clientId, + @required List items, +}) { + final localization = AppLocalization.of(context); + final store = StoreProvider.of(context); + final state = store.state; + + final invoices = state.invoiceState.map.values.where((invoice) { + if (clientId != invoice.clientId) { + return false; + } + + return invoice.isActive && !invoice.isPaid; + }); + + if (invoices.isEmpty) { + showMessageDialog(context: context, message: localization.noInvoicesFound); + return; + } + + showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(localization.addToInvoice), + children: invoices.map((invoice) { + return SimpleDialogOption( + child: Row(children: [ + Expanded(child: Text(invoice.number)), + Text( + formatNumber(invoice.amount, context, + clientId: invoice.clientId), + ), + ]), + onPressed: () { + editEntity( + context: context, + entity: invoice.rebuild( + (b) => b..lineItems.addAll(items), + )); + Navigator.of(context).pop(); + }, + ); + }).toList(), + ); + }); +} diff --git a/lib/utils/i18n.dart b/lib/utils/i18n.dart index 343cf669af4..6080c65026c 100644 --- a/lib/utils/i18n.dart +++ b/lib/utils/i18n.dart @@ -16,6 +16,9 @@ mixin LocalizationsProvider on LocaleCodeAware { static final Map> _localizedValues = { 'en': { // STARTER: lang key - do not remove comment + 'add_to_invoice': 'Add To Invoice', + 'no_invoices_found': 'No invoices found', + 'week': 'Week', 'created_record': 'Successfully created record', 'notification_invoice_sent': 'Invoice Sent', 'auto_archive_paid_invoices': 'Auto Archive Paid', @@ -75041,6 +75044,18 @@ mixin LocalizationsProvider on LocaleCodeAware { _localizedValues[localeCode]['created_record'] ?? _localizedValues[localeCode]['created_record']; + String get week => + _localizedValues[localeCode]['week'] ?? + _localizedValues[localeCode]['week']; + + String get addToInvoice => + _localizedValues[localeCode]['add_to_invoice'] ?? + _localizedValues[localeCode]['add_to_invoice']; + + String get noInvoicesFound => + _localizedValues[localeCode]['no_invoices_found'] ?? + _localizedValues[localeCode]['no_invoices_found']; + // STARTER: lang field - do not remove comment String lookup(String key) { diff --git a/lib/utils/icons.dart b/lib/utils/icons.dart index 66324eedb88..67053f466bf 100644 --- a/lib/utils/icons.dart +++ b/lib/utils/icons.dart @@ -70,6 +70,7 @@ IconData getEntityActionIcon(EntityAction entityAction) { case EntityAction.invoiceTask: case EntityAction.invoiceExpense: case EntityAction.invoiceProject: + case EntityAction.addToInvoice: return Icons.add_circle_outline; case EntityAction.resume: case EntityAction.start: diff --git a/pubspec.foss.yaml b/pubspec.foss.yaml index 5d577f70c5d..fc4401652b3 100644 --- a/pubspec.foss.yaml +++ b/pubspec.foss.yaml @@ -1,6 +1,6 @@ name: invoiceninja_flutter description: Client for Invoice Ninja -version: 5.0.83+83 +version: 5.0.84+84 homepage: https://invoiceninja.com documentation: https://invoiceninja.github.io publish_to: none diff --git a/pubspec.next.yaml b/pubspec.next.yaml index 6e67db8a568..94daf03d004 100644 --- a/pubspec.next.yaml +++ b/pubspec.next.yaml @@ -1,6 +1,6 @@ name: invoiceninja_flutter description: Client for Invoice Ninja -version: 5.0.83+83 +version: 5.0.84+84 homepage: https://invoiceninja.com documentation: https://invoiceninja.github.io publish_to: none diff --git a/pubspec.yaml b/pubspec.yaml index 56e300b042b..62c9f39eedb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: invoiceninja_flutter description: Client for Invoice Ninja -version: 5.0.83+83 +version: 5.0.84+84 homepage: https://invoiceninja.com documentation: https://invoiceninja.github.io publish_to: none diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 464b486b063..f32abe12444 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,5 +1,5 @@ name: invoiceninja -version: '5.0.83' +version: '5.0.84' summary: Create invoices, accept payments, track expenses & time-tasks description: "### Note: if the app fails to run using `snap run invoiceninja` it may help to run `/snap/invoiceninja/current/bin/invoiceninja` instead