diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a4657c92c7..d4457fb99f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,19 +19,25 @@ jobs: sentry_url: ${{secrets.sentry_url}} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - uses: subosito/flutter-action@v1 + - name: Checkout code + uses: actions/checkout@v1 + + - name: Setup Flutter + uses: subosito/flutter-action@v1 with: flutter-version: '3.13.6' #channel: 'stable' + - name: Install Sentry run: | curl -sL https://sentry.io/get-cli/ | bash - - name: Setup Flutter + + - name: Check Flutter run: | flutter doctor -v flutter pub get flutter config --enable-web + - name: Prepare App run: | cp lib/.env.dart.example lib/.env.dart @@ -39,6 +45,7 @@ jobs: echo "const FLUTTER_VERSION = const " > lib/flutter_version.dart flutter --version --machine >> lib/flutter_version.dart echo ";" >> lib/flutter_version.dart + - name: Build Hosted App run: | #export SENTRY_RELEASE=$(sentry-cli releases propose-version) @@ -70,6 +77,7 @@ jobs: #sentry-cli --auth-token ${{secrets.sentry_auth_token}} --url ${{secrets.sentry_url}} releases --org ${{secrets.sentry_org}} finalize $SENTRY_RELEASE #sentry-cli --auth-token ${{secrets.sentry_auth_token}} --url ${{secrets.sentry_url}} releases --org ${{secrets.sentry_org}} deploys $SENTRY_RELEASE new -e production + - name: Build Profile App run: | flutter build web --profile @@ -83,6 +91,7 @@ jobs: git commit -m 'Admin Portal - Profile' git push cd .. + - name: Build Selfhosted App run: | cp lib/utils/oauth.dart.foss lib/utils/oauth.dart diff --git a/lib/constants.dart b/lib/constants.dart index 318dcc1ada4..2722e3861bd 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -638,6 +638,7 @@ const String kReportPayment = 'payment'; const String kReportProduct = 'product'; const String kReportProfitAndLoss = 'profit_and_loss'; const String kReportTask = 'task'; +const String kReportTaskItem = 'task_item'; const String kReportInvoiceTax = 'invoice_tax'; const String kReportPaymentTax = 'payment_tax'; const String kReportQuote = 'quote'; @@ -736,6 +737,7 @@ const String kTaxRegionAustralia = 'AU'; const String kReportGroupDay = 'day'; const String kReportGroupWeek = 'week'; const String kReportGroupMonth = 'month'; +const String kReportGroupQuarter = 'quarter'; const String kReportGroupYear = 'year'; const int kModuleRecurringInvoices = 1; diff --git a/lib/data/models/entities.dart b/lib/data/models/entities.dart index 3c4447834c8..21569840f5b 100644 --- a/lib/data/models/entities.dart +++ b/lib/data/models/entities.dart @@ -131,6 +131,7 @@ class EntityType extends EnumClass { EntityType.task, EntityType.expense, EntityType.invoice, + EntityType.quote, ]; case EntityType.group: return [ diff --git a/lib/data/models/task_model.dart b/lib/data/models/task_model.dart index dccdedca681..1b4f3629517 100644 --- a/lib/data/models/task_model.dart +++ b/lib/data/models/task_model.dart @@ -287,6 +287,9 @@ abstract class TaskTime implements Built { ); } + double calculateAmount(double taskRate) => + taskRate * round(duration.inSeconds / 3600, 3); + static Serializer get serializer => _$taskTimeSerializer; } @@ -350,7 +353,7 @@ abstract class TaskEntity extends Object TaskEntity stop() { final times = getTaskTimes(); - final taskTime = times.last!.stop; + final taskTime = times.last.stop; return updateTaskTime(taskTime, times.length - 1); } @@ -373,7 +376,7 @@ abstract class TaskEntity extends Object bool isValid = true; times.forEach((time) { - final startDate = time!.startDate; + final startDate = time.startDate; final endDate = time.endDate; if (time.isRunning) { @@ -404,7 +407,7 @@ abstract class TaskEntity extends Object int counter = 0; times.forEach((time) { - final startDate = time!.startDate; + final startDate = time.startDate; final endDate = time.endDate; if (time.isRunning) { @@ -434,7 +437,7 @@ abstract class TaskEntity extends Object return false; } - return taskTimes.any((taskTime) => taskTime!.isRunning); + return taskTimes.any((taskTime) => taskTime.isRunning); } bool isBetween(String? startDate, String? endDate) { @@ -445,16 +448,16 @@ abstract class TaskEntity extends Object } final taskStartDate = - convertDateTimeToSqlDate(taskTimes.first!.startDate!.toLocal()); + convertDateTimeToSqlDate(taskTimes.first.startDate!.toLocal()); if (startDate!.compareTo(taskStartDate) <= 0 && endDate!.compareTo(taskStartDate) >= 0) { return true; } - final completedTimes = taskTimes.where((element) => !element!.isRunning); + final completedTimes = taskTimes.where((element) => !element.isRunning); if (completedTimes.isNotEmpty) { - final lastTaskTime = completedTimes.last!; + final lastTaskTime = completedTimes.last; final taskEndDate = convertDateTimeToSqlDate(lastTaskTime.endDate!.toLocal()); @@ -504,8 +507,8 @@ abstract class TaskEntity extends Object return last[1].round(); } - List getTaskTimes({bool sort = true}) { - final List details = []; + List getTaskTimes({bool sort = true}) { + final List details = []; if (timeLog.isEmpty) { return details; @@ -541,8 +544,8 @@ abstract class TaskEntity extends Object }); if (sort) { - details.sort( - (timeA, timeB) => timeA!.startDate!.compareTo(timeB!.startDate!)); + details + .sort((timeA, timeB) => timeA.startDate!.compareTo(timeB.startDate!)); } return details; @@ -588,8 +591,8 @@ abstract class TaskEntity extends Object int seconds = 0; getTaskTimes().forEach((taskTime) { - if (!onlyBillable || taskTime!.isBillable) { - seconds += taskTime!.duration.inSeconds; + if (!onlyBillable || taskTime.isBillable) { + seconds += taskTime.duration.inSeconds; } }); diff --git a/lib/data/web_client.dart b/lib/data/web_client.dart index 96a18fa78fe..67f728483c6 100644 --- a/lib/data/web_client.dart +++ b/lib/data/web_client.dart @@ -252,7 +252,8 @@ void _checkResponse(String url, http.Response response) { final minClientVersion = response.headers['x-minimum-client-version']; if (response.statusCode >= 500) { - throw _parseError(response.statusCode, response.body); + throw _parseError( + response.statusCode, response.body, response.reasonPhrase); } else if (serverVersion == null) { throw 'Error: please check that Invoice Ninja v5 is installed on the server\n\nURL: $url\n\nResponse: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}\n\nHeaders: ${response.headers}}'; } else if (Version.parse(kClientVersion) < Version.parse(minClientVersion!)) { @@ -260,7 +261,8 @@ void _checkResponse(String url, http.Response response) { } else if (Version.parse(serverVersion) < Version.parse(kMinServerVersion)) { throw 'Error: server not supported, please update to the latest version [Current v$serverVersion < Minimum v$kMinServerVersion]'; } else if (response.statusCode >= 400) { - throw _parseError(response.statusCode, response.body); + throw _parseError( + response.statusCode, response.body, response.reasonPhrase); } } @@ -277,8 +279,12 @@ void _preCheck() { */ } -String _parseError(int code, String response) { - dynamic message = response; +String _parseError(int code, String response, String? reason) { + String message = ''; + + if ((reason ?? '').isNotEmpty) { + message += reason! + ' • '; + } if (response.contains('DOCTYPE html')) { return '$code: An error occurred'; @@ -287,7 +293,7 @@ String _parseError(int code, String response) { try { final dynamic jsonResponse = json.decode(response); - message = jsonResponse['message'] ?? jsonResponse; + message += jsonResponse['message'] ?? jsonResponse; if (jsonResponse['errors'] != null && (jsonResponse['errors'] as Map).isNotEmpty) { diff --git a/lib/redux/dashboard/dashboard_selectors.dart b/lib/redux/dashboard/dashboard_selectors.dart index fd4d4944d00..d6830779164 100644 --- a/lib/redux/dashboard/dashboard_selectors.dart +++ b/lib/redux/dashboard/dashboard_selectors.dart @@ -688,7 +688,7 @@ List chartTasks( // skip it } else { task.getTaskTimes().forEach((taskTime) { - taskTime!.getParts().forEach((date, duration) { + taskTime.getParts().forEach((date, duration) { if (settings.groupBy == kReportGroupYear) { date = date.substring(0, 4) + '-01-01'; } else if (settings.groupBy == kReportGroupMonth) { diff --git a/lib/redux/project/project_selectors.dart b/lib/redux/project/project_selectors.dart index adc8f173bc3..be0141251b4 100644 --- a/lib/redux/project/project_selectors.dart +++ b/lib/redux/project/project_selectors.dart @@ -43,8 +43,8 @@ List convertProjectToInvoiceItem({ final taskTimesA = taskA!.getTaskTimes(); final taskTimesB = taskB!.getTaskTimes(); - final taskADate = taskTimesA.isEmpty ? null : taskTimesA.first!.startDate; - final taskBDate = taskTimesB.isEmpty ? null : taskTimesB.first!.startDate; + final taskADate = taskTimesA.isEmpty ? null : taskTimesA.first.startDate; + final taskBDate = taskTimesB.isEmpty ? null : taskTimesB.first.startDate; if (taskADate == null) { return 1; diff --git a/lib/redux/task/task_actions.dart b/lib/redux/task/task_actions.dart index 50fb3ea2d83..cba789d696b 100644 --- a/lib/redux/task/task_actions.dart +++ b/lib/redux/task/task_actions.dart @@ -420,10 +420,10 @@ void handleTaskAction( final taskBTimes = taskBEntity.getTaskTimes(); final taskADate = taskATimes.isEmpty ? convertTimestampToDate(taskA.createdAt) - : taskATimes.first!.startDate!; + : taskATimes.first.startDate!; final taskBDate = taskBTimes.isEmpty ? convertTimestampToDate(taskB.createdAt) - : taskBTimes.first!.startDate!; + : taskBTimes.first.startDate!; return taskADate.compareTo(taskBDate); }); diff --git a/lib/redux/task/task_selectors.dart b/lib/redux/task/task_selectors.dart index 7c662d7d2bf..39c5cfc3cc8 100644 --- a/lib/redux/task/task_selectors.dart +++ b/lib/redux/task/task_selectors.dart @@ -58,9 +58,9 @@ InvoiceItemEntity convertTaskToInvoiceItem({ task .getTaskTimes() .where((time) => - time!.startDate != null && time.endDate != null && time.isBillable) + time.startDate != null && time.endDate != null && time.isBillable) .forEach((time) { - final hours = round(time!.duration.inSeconds / 3600, 3); + final hours = round(time.duration.inSeconds / 3600, 3); final hoursStr = hours == 1 ? ' • 1 ${localization.hour}' : ' • $hours ${localization.hours}'; diff --git a/lib/ui/invoice/invoice_pdf.dart b/lib/ui/invoice/invoice_pdf.dart index 89c3ff9b016..19b30188fe5 100644 --- a/lib/ui/invoice/invoice_pdf.dart +++ b/lib/ui/invoice/invoice_pdf.dart @@ -29,7 +29,6 @@ import 'package:invoiceninja_flutter/ui/app/forms/app_dropdown_button.dart'; import 'package:invoiceninja_flutter/ui/app/loading_indicator.dart'; import 'package:invoiceninja_flutter/ui/app/presenters/entity_presenter.dart'; import 'package:invoiceninja_flutter/ui/invoice/invoice_pdf_vm.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'; @@ -362,7 +361,6 @@ Future _loadPDF( errorMessage += response.body; } - showErrorDialog(message: errorMessage); throw errorMessage; } diff --git a/lib/ui/reports/reports_screen.dart b/lib/ui/reports/reports_screen.dart index a30bf0478d6..15877ebd811 100644 --- a/lib/ui/reports/reports_screen.dart +++ b/lib/ui/reports/reports_screen.dart @@ -119,7 +119,10 @@ class ReportsScreen extends StatelessWidget { ], kReportProduct, kReportProfitAndLoss, - kReportTask, + if (state.company.isModuleEnabled(EntityType.task)) ...[ + kReportTask, + kReportTaskItem, + ], if (state.company.isModuleEnabled(EntityType.vendor)) ...[ kReportVendor, if (state.company.isModuleEnabled(EntityType.purchaseOrder)) @@ -187,6 +190,10 @@ class ReportsScreen extends StatelessWidget { child: Text(localization.month), value: kReportGroupMonth, ), + DropdownMenuItem( + child: Text(localization.quarter), + value: kReportGroupQuarter, + ), DropdownMenuItem( child: Text(localization.year), value: kReportGroupYear, @@ -1390,6 +1397,9 @@ class ReportResult { customStartDate = group; if (reportState.subgroup == kReportGroupDay) { customEndDate = convertDateTimeToSqlDate(date); + } else if (reportState.subgroup == kReportGroupQuarter) { + customEndDate = + convertDateTimeToSqlDate(addDays(addMonths(date!, 3), -1)); } else if (reportState.subgroup == kReportGroupMonth) { customEndDate = convertDateTimeToSqlDate(addDays(addMonths(date!, 1), -1)); diff --git a/lib/ui/reports/reports_screen_vm.dart b/lib/ui/reports/reports_screen_vm.dart index 8df0d9befef..58ef636575a 100644 --- a/lib/ui/reports/reports_screen_vm.dart +++ b/lib/ui/reports/reports_screen_vm.dart @@ -16,6 +16,7 @@ import 'package:invoiceninja_flutter/ui/reports/purchase_order_item_report.dart' import 'package:invoiceninja_flutter/ui/reports/purchase_order_report.dart'; import 'package:invoiceninja_flutter/ui/reports/recurring_expense_report.dart'; import 'package:invoiceninja_flutter/ui/reports/recurring_invoice_report.dart'; +import 'package:invoiceninja_flutter/ui/reports/task_item_report.dart'; import 'package:invoiceninja_flutter/ui/reports/transaction_report.dart'; import 'package:invoiceninja_flutter/ui/reports/vendor_report.dart'; import 'package:invoiceninja_flutter/utils/files.dart'; @@ -214,6 +215,20 @@ class ReportsScreenVM { state.staticState, ); break; + case kReportTaskItem: + reportResult = memoizedTaskItemReport( + state.userCompany, + state.uiState.reportsUIState, + state.taskState.map, + state.invoiceState.map, + state.groupState.map, + state.clientState.map, + state.taskStatusState.map, + state.userState.map, + state.projectState.map, + state.staticState, + ); + break; case kReportQuote: reportResult = memoizedQuoteReport( state.userCompany, @@ -637,6 +652,19 @@ GroupTotals calculateReportTotals({ group = group.substring(0, 4) + '-01-01'; } else if (reportState.subgroup == kReportGroupMonth) { group = group.substring(0, 7) + '-01'; + } else if (reportState.subgroup == kReportGroupQuarter) { + final parts = group.split('-'); + final month = parseInt(parts[1]) ?? 0; + group = parts[0] + '-'; + if (month <= 3) { + group += '01-01'; + } else if (month <= 6) { + group += '04-01'; + } else if (month <= 9) { + group += '07-01'; + } else { + group += '10-01'; + } } else if (reportState.subgroup == kReportGroupWeek) { final date = DateTime.parse(group); final dateWeek = diff --git a/lib/ui/reports/task_item_report.dart b/lib/ui/reports/task_item_report.dart new file mode 100644 index 00000000000..1d5236912e1 --- /dev/null +++ b/lib/ui/reports/task_item_report.dart @@ -0,0 +1,315 @@ +// Package imports: +import 'package:built_collection/built_collection.dart'; +import 'package:collection/collection.dart' show IterableNullableExtension; +import 'package:invoiceninja_flutter/main_app.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:invoiceninja_flutter/redux/reports/reports_selectors.dart'; +import 'package:memoize/memoize.dart'; + +// Project imports: +import 'package:invoiceninja_flutter/constants.dart'; +import 'package:invoiceninja_flutter/data/models/group_model.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/reports/reports_state.dart'; +import 'package:invoiceninja_flutter/redux/static/static_state.dart'; +import 'package:invoiceninja_flutter/redux/task/task_selectors.dart'; +import 'package:invoiceninja_flutter/ui/reports/reports_screen.dart'; +import 'package:invoiceninja_flutter/utils/enums.dart'; +import 'package:invoiceninja_flutter/utils/formatting.dart'; + +enum TaskItemReportFields { + number, + id, + rate, + calculated_rate, + start_time, + end_time, + duration, + description, + item_description, + invoice, + invoice_date, + invoice_due_date, + project, + client, + client_balance, + client_address1, + client_address2, + client_shipping_address1, + client_shipping_address2, + task1, + task2, + task3, + task4, + status, + assigned_to, + created_by, + amount, + record_state, + is_invoiced, +} + +var memoizedTaskItemReport = memo10(( + UserCompanyEntity? userCompany, + ReportsUIState reportsUIState, + BuiltMap taskMap, + BuiltMap invoiceMap, + BuiltMap groupMap, + BuiltMap clientMap, + BuiltMap taskStatusMap, + BuiltMap userMap, + BuiltMap projectMap, + StaticState staticState, +) => + taskItemReport( + userCompany!, + reportsUIState, + taskMap, + invoiceMap, + groupMap, + clientMap, + taskStatusMap, + userMap, + projectMap, + staticState, + )); + +ReportResult taskItemReport( + UserCompanyEntity userCompany, + ReportsUIState reportsUIState, + BuiltMap taskMap, + BuiltMap invoiceMap, + BuiltMap groupMap, + BuiltMap clientMap, + BuiltMap taskStatusMap, + BuiltMap userMap, + BuiltMap projectMap, + StaticState staticState, +) { + final List> data = []; + final List entities = []; + BuiltList columns; + + final reportSettings = userCompany.settings.reportSettings; + final taskReportSettings = reportSettings.containsKey(kReportTaskItem) + ? reportSettings[kReportTaskItem]! + : ReportSettingsEntity(); + + final defaultColumns = [ + TaskItemReportFields.number, + TaskItemReportFields.start_time, + TaskItemReportFields.end_time, + TaskItemReportFields.duration, + TaskItemReportFields.description, + TaskItemReportFields.client, + TaskItemReportFields.project, + TaskItemReportFields.invoice, + TaskItemReportFields.status, + ]; + + if (taskReportSettings.columns.isNotEmpty) { + columns = BuiltList(taskReportSettings.columns + .map((e) => EnumUtils.fromString(TaskItemReportFields.values, e)) + .whereNotNull() + .toList()); + } else { + columns = BuiltList(defaultColumns); + } + + for (var taskId in taskMap.keys) { + final task = taskMap[taskId]!; + final client = clientMap[task.clientId] ?? ClientEntity(); + final invoice = invoiceMap[task.invoiceId] ?? InvoiceEntity(); + final project = projectMap[task.projectId] ?? ProjectEntity(); + final group = groupMap[client.groupId] ?? GroupEntity(); + + if ((task.isDeleted! && !userCompany.company.reportIncludeDeleted) || + client.isDeleted!) { + continue; + } + + for (var taskItem in task.getTaskTimes()) { + bool skip = false; + final List row = []; + + for (var column in columns) { + dynamic value = ''; + + switch (column) { + case TaskItemReportFields.id: + value = task.id; + break; + case TaskItemReportFields.number: + value = task.number; + break; + case TaskItemReportFields.rate: + value = task.rate; + break; + case TaskItemReportFields.calculated_rate: + value = taskRateSelector( + company: userCompany.company, + project: project, + client: client, + task: task, + group: group, + ); + break; + case TaskItemReportFields.start_time: + if (taskItem.startDate == null) { + value = ''; + } else { + final timestamp = + (taskItem.startDate!.millisecondsSinceEpoch / 1000).floor(); + value = + timestamp > 0 ? convertTimestampToDateString(timestamp) : ''; + } + break; + case TaskItemReportFields.end_time: + if (taskItem.endDate == null) { + value = ''; + } else { + final timestamp = + (taskItem.endDate!.millisecondsSinceEpoch / 1000).floor(); + value = + timestamp > 0 ? convertTimestampToDateString(timestamp) : ''; + } + break; + case TaskItemReportFields.description: + value = task.description; + break; + case TaskItemReportFields.item_description: + value = taskItem.description; + break; + case TaskItemReportFields.invoice: + value = invoice.listDisplayName; + break; + case TaskItemReportFields.invoice_date: + value = invoice.isNew ? '' : invoice.date; + break; + case TaskItemReportFields.invoice_due_date: + value = invoice.isNew ? '' : invoice.dueDate; + break; + case TaskItemReportFields.duration: + value = taskItem.duration.inSeconds; + break; + case TaskItemReportFields.project: + value = projectMap[task.projectId]?.name ?? ''; + break; + case TaskItemReportFields.client: + value = clientMap[task.clientId]?.displayName ?? ''; + break; + case TaskItemReportFields.client_balance: + value = client.balance; + break; + case TaskItemReportFields.client_address1: + value = client.address1; + break; + case TaskItemReportFields.client_address2: + value = client.address2; + break; + case TaskItemReportFields.client_shipping_address1: + value = client.shippingAddress1; + break; + case TaskItemReportFields.client_shipping_address2: + value = client.shippingAddress2; + break; + case TaskItemReportFields.task1: + value = presentCustomField( + value: task.customValue1, + customFieldType: CustomFieldType.task1, + company: userCompany.company, + ); + break; + case TaskItemReportFields.task2: + value = presentCustomField( + value: task.customValue2, + customFieldType: CustomFieldType.task2, + company: userCompany.company, + ); + break; + case TaskItemReportFields.task3: + value = presentCustomField( + value: task.customValue3, + customFieldType: CustomFieldType.task3, + company: userCompany.company, + ); + break; + case TaskItemReportFields.task4: + value = presentCustomField( + value: task.customValue4, + customFieldType: CustomFieldType.task4, + company: userCompany.company, + ); + break; + case TaskItemReportFields.status: + value = taskStatusMap[task.statusId]?.name ?? ''; + break; + case TaskItemReportFields.assigned_to: + value = userMap[task.assignedUserId]?.listDisplayName ?? ''; + break; + case TaskItemReportFields.created_by: + value = userMap[task.createdUserId]?.listDisplayName ?? ''; + break; + case TaskItemReportFields.amount: + value = taskItem.calculateAmount( + taskRateSelector( + company: userCompany.company, + project: project, + client: client, + task: task, + group: group, + )!, + ); + break; + case TaskItemReportFields.record_state: + value = AppLocalization.of(navigatorKey.currentContext!)! + .lookup(task.entityState); + break; + case TaskItemReportFields.is_invoiced: + value = task.isInvoiced; + break; + } + + if (!ReportResult.matchField( + value: value, + userCompany: userCompany, + reportsUIState: reportsUIState, + column: EnumUtils.parse(column), + )!) { + skip = true; + } + + if (column == TaskItemReportFields.duration) { + row.add(task.getReportDuration( + value: value, currencyId: client.currencyId)); + } else if (value.runtimeType == bool) { + row.add(task.getReportBool(value: value)); + } else if (value.runtimeType == double || value.runtimeType == int) { + row.add(task.getReportDouble( + value: value, currencyId: client.settings.currencyId)); + } else { + row.add(task.getReportString(value: value)); + } + } + + if (!skip) { + data.add(row); + entities.add(task); + } + } + } + + final selectedColumns = columns.map((item) => EnumUtils.parse(item)).toList(); + data.sort((rowA, rowB) => + sortReportTableRows(rowA, rowB, taskReportSettings, selectedColumns)!); + + return ReportResult( + allColumns: + TaskItemReportFields.values.map((e) => EnumUtils.parse(e)).toList(), + columns: selectedColumns, + defaultColumns: + defaultColumns.map((item) => EnumUtils.parse(item)).toList(), + data: data, + entities: entities, + ); +} diff --git a/lib/ui/reports/task_report.dart b/lib/ui/reports/task_report.dart index 0a0541a9335..e915a39ffec 100644 --- a/lib/ui/reports/task_report.dart +++ b/lib/ui/reports/task_report.dart @@ -46,6 +46,7 @@ enum TaskReportFields { created_by, amount, record_state, + is_invoiced, } var memoizedTaskReport = memo10(( @@ -253,6 +254,10 @@ ReportResult taskReport( case TaskReportFields.record_state: value = AppLocalization.of(navigatorKey.currentContext!)! .lookup(task.entityState); + break; + case TaskReportFields.is_invoiced: + value = task.isInvoiced; + break; } if (!ReportResult.matchField( diff --git a/lib/ui/task/edit/task_edit_desktop.dart b/lib/ui/task/edit/task_edit_desktop.dart index 0e852fa9204..d9cc0dbe485 100644 --- a/lib/ui/task/edit/task_edit_desktop.dart +++ b/lib/ui/task/edit/task_edit_desktop.dart @@ -129,7 +129,7 @@ class _TaskEditDesktopState extends State { final showEndDate = company.showTaskEndDate; final taskTimes = task.getTaskTimes(sort: false); - if (!taskTimes.any((taskTime) => taskTime!.isEmpty)) { + if (!taskTimes.any((taskTime) => taskTime.isEmpty)) { taskTimes.add(TaskTime().rebuild((b) => b..startDate = null)); } @@ -333,15 +333,13 @@ class _TaskEditDesktopState extends State { ? localization.startDate : null, selectedDate: - taskTimes[index]!.startDate == null + taskTimes[index].startDate == null ? null : convertDateTimeToSqlDate( - taskTimes[index]! - .startDate! + taskTimes[index].startDate! .toLocal()), onSelected: (date, _) { - final taskTime = taskTimes[index]! - .copyWithStartDate(date, + final taskTime = taskTimes[index].copyWithStartDate(date, syncDates: !showEndDate); viewModel.onUpdatedTaskTime( taskTime, index); @@ -363,14 +361,13 @@ class _TaskEditDesktopState extends State { labelText: settings.showTaskItemDescription! ? localization.startTime : null, - selectedDateTime: taskTimes[index]!.startDate, + selectedDateTime: taskTimes[index].startDate, onSelected: (timeOfDay) { if (timeOfDay == null) { return; } - final taskTime = taskTimes[index]! - .copyWithStartTime(timeOfDay); + final taskTime = taskTimes[index].copyWithStartTime(timeOfDay); viewModel.onUpdatedTaskTime( taskTime, index); setState(() { @@ -393,15 +390,13 @@ class _TaskEditDesktopState extends State { ? localization.endDate : null, selectedDate: - taskTimes[index]!.endDate == null + taskTimes[index].endDate == null ? null : convertDateTimeToSqlDate( - taskTimes[index]! - .endDate! + taskTimes[index].endDate! .toLocal()), onSelected: (date, _) { - final taskTime = taskTimes[index]! - .copyWithEndDate(date); + final taskTime = taskTimes[index].copyWithEndDate(date); viewModel.onUpdatedTaskTime( taskTime, index); setState(() { @@ -422,15 +417,14 @@ class _TaskEditDesktopState extends State { labelText: settings.showTaskItemDescription! ? localization.endTime : null, - selectedDateTime: taskTimes[index]!.endDate, + selectedDateTime: taskTimes[index].endDate, isEndTime: true, onSelected: (timeOfDay) { if (timeOfDay == null) { return; } - final taskTime = taskTimes[index]! - .copyWithEndTime(timeOfDay); + final taskTime = taskTimes[index].copyWithEndTime(timeOfDay); viewModel.onUpdatedTaskTime( taskTime, index); setState(() { @@ -452,8 +446,7 @@ class _TaskEditDesktopState extends State { ? localization.duration : null, onSelected: (Duration duration) { - final taskTime = taskTimes[index]! - .copyWithDuration(duration); + final taskTime = taskTimes[index].copyWithDuration(duration); viewModel.onUpdatedTaskTime( taskTime, index); setState(() { @@ -462,10 +455,10 @@ class _TaskEditDesktopState extends State { }); }, selectedDuration: - (taskTimes[index]!.startDate == null || - taskTimes[index]!.endDate == null) + (taskTimes[index].startDate == null || + taskTimes[index].endDate == null) ? null - : taskTimes[index]!.duration, + : taskTimes[index].duration, ), ), ), @@ -477,7 +470,7 @@ class _TaskEditDesktopState extends State { const EdgeInsets.only(bottom: 16, right: 16), child: GrowableFormField( label: localization.description, - initialValue: taskTime!.description, + initialValue: taskTime.description, onChanged: (value) { viewModel.onUpdatedTaskTime( taskTime @@ -493,7 +486,7 @@ class _TaskEditDesktopState extends State { Padding( padding: const EdgeInsets.only(right: 8, left: 4), child: IconButton( - tooltip: taskTime!.isBillable + tooltip: taskTime.isBillable ? localization.billable : localization.notBillable, onPressed: taskTime.isEmpty @@ -514,7 +507,7 @@ class _TaskEditDesktopState extends State { tooltip: overlapping.contains(index) ? localization.invalidTime : localization.remove, - onPressed: taskTimes[index]!.isEmpty + onPressed: taskTimes[index].isEmpty ? null : () { confirmCallback( diff --git a/lib/ui/task/edit/task_edit_times.dart b/lib/ui/task/edit/task_edit_times.dart index 4e2b2a32f3c..46843daa9fa 100644 --- a/lib/ui/task/edit/task_edit_times.dart +++ b/lib/ui/task/edit/task_edit_times.dart @@ -42,7 +42,7 @@ class _TaskEditTimesState extends State { viewModel: viewModel, taskTime: taskTime, index: taskTimes.indexOf( - taskTimes.firstWhere((time) => time!.equalTo(taskTime!))), + taskTimes.firstWhere((time) => time.equalTo(taskTime!))), ); }); } diff --git a/lib/ui/task/edit/task_edit_vm.dart b/lib/ui/task/edit/task_edit_vm.dart index 8b406ed5b54..ed162abd30c 100644 --- a/lib/ui/task/edit/task_edit_vm.dart +++ b/lib/ui/task/edit/task_edit_vm.dart @@ -76,7 +76,7 @@ class TaskEditVM { final taskTimes = task.getTaskTimes(); store.dispatch(UpdateTaskTime( index: taskTimes.length - 1, - taskTime: taskTimes.firstWhere((time) => time!.isRunning)!.stop)); + taskTime: taskTimes.firstWhere((time) => time.isRunning).stop)); } else { store.dispatch(AddTaskTime(TaskTime())); } diff --git a/lib/ui/task/task_presenter.dart b/lib/ui/task/task_presenter.dart index 1c1422d845d..fc031c4e4be 100644 --- a/lib/ui/task/task_presenter.dart +++ b/lib/ui/task/task_presenter.dart @@ -96,9 +96,9 @@ class TaskPresenter extends EntityPresenter { final notes = []; task .getTaskTimes() - .where((time) => time!.startDate != null && time.endDate != null) + .where((time) => time.startDate != null && time.endDate != null) .forEach((time) { - final start = formatDate(time!.startDate!.toIso8601String(), context, + final start = formatDate(time.startDate!.toIso8601String(), context, showTime: true, showDate: true); final end = formatDate(time.endDate!.toIso8601String(), context, showTime: true, showDate: false); diff --git a/lib/ui/task/view/task_view_vm.dart b/lib/ui/task/view/task_view_vm.dart index 22f840e043c..0692aaa44ea 100644 --- a/lib/ui/task/view/task_view_vm.dart +++ b/lib/ui/task/view/task_view_vm.dart @@ -108,7 +108,8 @@ class TaskViewVM { onEditPressed: (BuildContext context, [TaskTime? taskTime]) { editEntity( entity: task, - subIndex: task.getTaskTimes().indexOf(taskTime), + subIndex: + taskTime != null ? task.getTaskTimes().indexOf(taskTime) : 0, completer: snackBarCompleter( AppLocalization.of(context)!.updatedTask)); }, diff --git a/lib/utils/i18n.dart b/lib/utils/i18n.dart index 98324019e09..723bbbe9eab 100644 --- a/lib/utils/i18n.dart +++ b/lib/utils/i18n.dart @@ -18,6 +18,9 @@ mixin LocalizationsProvider on LocaleCodeAware { static final Map> _localizedValues = { 'en': { // STARTER: lang key - do not remove comment + 'quarter': 'Quarter', + 'item_description': 'Item Description', + 'task_item': 'Task Item', 'record_state': 'Record State', 'last_login': 'Last Login', 'save_files_to_this_folder': 'Save files to this folder', @@ -109934,6 +109937,15 @@ mixin LocalizationsProvider on LocaleCodeAware { _localizedValues[localeCode]!['record_state'] ?? _localizedValues['en']!['record_state']!; + String get taskItem => + _localizedValues[localeCode]!['task_item'] ?? + _localizedValues['en']!['task_item']!; + + String get quarter => + _localizedValues[localeCode]!['quarter'] ?? + _localizedValues['en']!['quarter']!; + + // STARTER: lang field - do not remove comment String lookup(String? key) {