Refactor tender detail models and UI components for improved clarity and functionality
- Updated the `Lot`, `Organization`, and `OrganizationAddress` models to enhance data mapping for tender details. - Revised the `TenderData` model to include additional fields reflecting the API response structure. - Improved UI components across desktop, mobile, and tablet views by commenting out unused sections for future use. - Added new string constants for lot details in the tender information section. - Enhanced unit tests to cover new model fields and ensure resilience to partial API payloads.
This commit is contained in:
@@ -5,9 +5,9 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
part 'lot.freezed.dart';
|
part 'lot.freezed.dart';
|
||||||
part 'lot.g.dart';
|
part 'lot.g.dart';
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): New model for entries in the tender-details
|
// Model for entries in the tender-details `lots` array. Only lot_id, title and
|
||||||
// `lots` array. Only lot_id, title and description are mapped — the fields the
|
// description are mapped — the fields the tender-details "Lots" section renders.
|
||||||
// tender-details "Lots" section renders. Add more fields here if the UI grows.
|
// Add more fields here if the UI grows.
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Lot with _$Lot {
|
abstract class Lot with _$Lot {
|
||||||
const factory Lot({
|
const factory Lot({
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import 'package:tm_app/data/services/model/organization/organization_address.dar
|
|||||||
part 'organization.freezed.dart';
|
part 'organization.freezed.dart';
|
||||||
part 'organization.g.dart';
|
part 'organization.g.dart';
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): Added company_id and a nested address
|
// company_id and a nested address (OrganizationAddress) surface buyer details
|
||||||
// (OrganizationAddress) to surface buyer details on the tender-details page.
|
// on the tender-details page. A dedicated OrganizationAddress type is used
|
||||||
// A dedicated OrganizationAddress type is used instead of the existing
|
// instead of the existing `Address` model because the buyer address shape
|
||||||
// `Address` model because the buyer address shape differs (city_name +
|
// differs (city_name + country_code only) and is unrelated to the user-profile
|
||||||
// country_code only) and is unrelated to the user-profile Address.
|
// Address.
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Organization with _$Organization {
|
abstract class Organization with _$Organization {
|
||||||
const factory Organization({
|
const factory Organization({
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
part 'organization_address.freezed.dart';
|
part 'organization_address.freezed.dart';
|
||||||
part 'organization_address.g.dart';
|
part 'organization_address.g.dart';
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): New model for the buyer_organization.address
|
// Model for the buyer_organization.address object from the tender-details API.
|
||||||
// object from the tender-details API. Kept separate from the existing profile
|
// Kept separate from the existing profile `Address` model because the shapes
|
||||||
// `Address` model because the shapes differ (this one only needs city_name and
|
// differ (this one only needs city_name and country_code). Only the fields the
|
||||||
// country_code). Only the fields the UI shows are mapped.
|
// UI shows are mapped.
|
||||||
@freezed
|
@freezed
|
||||||
abstract class OrganizationAddress with _$OrganizationAddress {
|
abstract class OrganizationAddress with _$OrganizationAddress {
|
||||||
const factory OrganizationAddress({
|
const factory OrganizationAddress({
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import 'package:tm_app/data/services/model/organization/organization.dart';
|
|||||||
part 'tender_data.freezed.dart';
|
part 'tender_data.freezed.dart';
|
||||||
part 'tender_data.g.dart';
|
part 'tender_data.g.dart';
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): New fields were added to mirror the
|
// Fields mirror the /tenders/details/:id response so the tender-details page
|
||||||
// /tenders/details/:id response so the tender-details page can show them:
|
// can show them: notice_type_code, form_type, notice_language_code, issue_date,
|
||||||
// notice_type_code, form_type, notice_language_code, issue_date, issue_time,
|
// issue_time, lots, official_languages. issue_date/issue_time reuse
|
||||||
// lots, official_languages. issue_date/issue_time reuse unixTimestampFromJson
|
// unixTimestampFromJson (epoch seconds, tolerant of ms/string) like the other
|
||||||
// (epoch seconds, tolerant of ms/string) like the other timestamp fields. All
|
// timestamp fields. All fields are nullable to stay resilient to partial API
|
||||||
// fields are nullable to stay resilient to partial API payloads. Freezed/json
|
// payloads.
|
||||||
// codegen (.freezed.dart/.g.dart) was regenerated for these.
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class TenderData with _$TenderData {
|
abstract class TenderData with _$TenderData {
|
||||||
const factory TenderData({
|
const factory TenderData({
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import 'package:tm_app/core/theme/colors.dart';
|
|||||||
import 'package:tm_app/core/utils/size_config.dart';
|
import 'package:tm_app/core/utils/size_config.dart';
|
||||||
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
||||||
import 'package:tm_app/views/detail/widgets/tender_detail_action.dart';
|
import 'package:tm_app/views/detail/widgets/tender_detail_action.dart';
|
||||||
// AI-NOTE (for PR reviewer): Import kept (commented) — the TenderDetailCard
|
|
||||||
// (static "profile match / incomplete resume" card) is disabled, not removed.
|
|
||||||
// import 'package:tm_app/views/detail/widgets/tender_detail_card.dart';
|
|
||||||
import 'package:tm_app/views/detail/widgets/tender_detail_header.dart';
|
import 'package:tm_app/views/detail/widgets/tender_detail_header.dart';
|
||||||
|
|
||||||
import '../../../core/constants/tender_approval_status.dart';
|
import '../../../core/constants/tender_approval_status.dart';
|
||||||
@@ -123,10 +120,6 @@ class _TenderDetailDesktopPageState extends State<TenderDetailDesktopPage> {
|
|||||||
detail: detail,
|
detail: detail,
|
||||||
),
|
),
|
||||||
SizedBox(height: 32.0.h()),
|
SizedBox(height: 32.0.h()),
|
||||||
// AI-NOTE (for PR reviewer): Static mock card
|
|
||||||
// disabled (kept for future) — hard-coded data.
|
|
||||||
// TenderDetailCard(detail: detail),
|
|
||||||
// SizedBox(height: 24.0.h()),
|
|
||||||
TenderDetailActions(
|
TenderDetailActions(
|
||||||
isScreenBig: true,
|
isScreenBig: true,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import 'package:tm_app/core/utils/size_config.dart';
|
|||||||
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
||||||
import 'package:tm_app/views/detail/strings/tender_details_strings.dart';
|
import 'package:tm_app/views/detail/strings/tender_details_strings.dart';
|
||||||
import 'package:tm_app/views/detail/widgets/tender_detail_action.dart';
|
import 'package:tm_app/views/detail/widgets/tender_detail_action.dart';
|
||||||
// AI-NOTE (for PR reviewer): Import kept (commented) — the TenderDetailCard
|
|
||||||
// (static "profile match / incomplete resume" card) is disabled, not removed.
|
|
||||||
// import 'package:tm_app/views/detail/widgets/tender_detail_card.dart';
|
|
||||||
import 'package:tm_app/views/detail/widgets/tender_detail_header.dart';
|
import 'package:tm_app/views/detail/widgets/tender_detail_header.dart';
|
||||||
import 'package:tm_app/views/shared/tender_app_bar.dart';
|
import 'package:tm_app/views/shared/tender_app_bar.dart';
|
||||||
|
|
||||||
@@ -114,10 +111,6 @@ class _TenderDetailMobilePageState extends State<TenderDetailMobilePage> {
|
|||||||
children: [
|
children: [
|
||||||
TenderDetailHeader(isScreenBig: false, detail: detail),
|
TenderDetailHeader(isScreenBig: false, detail: detail),
|
||||||
SizedBox(height: 24.0.h()),
|
SizedBox(height: 24.0.h()),
|
||||||
// AI-NOTE (for PR reviewer): Static mock card disabled (kept
|
|
||||||
// for future use) — shows hard-coded profile-match data.
|
|
||||||
// TenderDetailCard(detail: detail),
|
|
||||||
// SizedBox(height: 24.0.h()),
|
|
||||||
TenderDetailActions(
|
TenderDetailActions(
|
||||||
isScreenBig: false,
|
isScreenBig: false,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import 'package:tm_app/core/theme/colors.dart';
|
|||||||
import 'package:tm_app/core/utils/size_config.dart';
|
import 'package:tm_app/core/utils/size_config.dart';
|
||||||
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
||||||
import 'package:tm_app/views/detail/widgets/tender_detail_action.dart';
|
import 'package:tm_app/views/detail/widgets/tender_detail_action.dart';
|
||||||
// AI-NOTE (for PR reviewer): Import kept (commented) — the TenderDetailCard
|
|
||||||
// (static "profile match / incomplete resume" card) is disabled, not removed.
|
|
||||||
// import 'package:tm_app/views/detail/widgets/tender_detail_card.dart';
|
|
||||||
import 'package:tm_app/views/detail/widgets/tender_detail_header.dart';
|
import 'package:tm_app/views/detail/widgets/tender_detail_header.dart';
|
||||||
import 'package:tm_app/views/shared/tablet_navigation_widget.dart';
|
import 'package:tm_app/views/shared/tablet_navigation_widget.dart';
|
||||||
|
|
||||||
@@ -125,10 +122,6 @@ class _TenderDetailTabletPageState extends State<TenderDetailTabletPage> {
|
|||||||
SizedBox(height: 28.0.h()),
|
SizedBox(height: 28.0.h()),
|
||||||
TenderDetailHeader(isScreenBig: true, detail: detail),
|
TenderDetailHeader(isScreenBig: true, detail: detail),
|
||||||
SizedBox(height: 28.0.h()),
|
SizedBox(height: 28.0.h()),
|
||||||
// AI-NOTE (for PR reviewer): Static mock card disabled
|
|
||||||
// (kept for future) — hard-coded profile-match data.
|
|
||||||
// TenderDetailCard(detail: detail),
|
|
||||||
// SizedBox(height: 24.0.h()),
|
|
||||||
TenderDetailActions(
|
TenderDetailActions(
|
||||||
isScreenBig: true,
|
isScreenBig: true,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ class TenderDetailsStrings {
|
|||||||
static const String requestSuccessfullyRegistered =
|
static const String requestSuccessfullyRegistered =
|
||||||
'Your request has been successfully registered.';
|
'Your request has been successfully registered.';
|
||||||
static const String confirmAndClose = 'Confirm and close';
|
static const String confirmAndClose = 'Confirm and close';
|
||||||
// AI-NOTE (for PR reviewer): Formal toast copy shown after the user confirms
|
// Toast copy shown after the user confirms the Accept/submission modal
|
||||||
// the Accept/submission modal (see tender_detail_action.dart).
|
// (see tender_detail_action.dart).
|
||||||
static const String submissionReceived =
|
static const String submissionReceived =
|
||||||
'Thank you. We have received your submission and will be in touch with you shortly.';
|
'Thank you. We have received your submission and will be in touch with you shortly.';
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): Field labels added for the rewritten
|
// Field labels for the tender-details info section (one per backend field
|
||||||
// tender-details info section (one per backend field rendered there).
|
// rendered there).
|
||||||
// Tender detail field labels
|
// Tender detail field labels
|
||||||
static const String noticeTypeCode = 'Notice Type';
|
static const String noticeTypeCode = 'Notice Type';
|
||||||
static const String formType = 'Form Type';
|
static const String formType = 'Form Type';
|
||||||
@@ -71,4 +71,6 @@ class TenderDetailsStrings {
|
|||||||
static const String tenderId = 'Tender ID';
|
static const String tenderId = 'Tender ID';
|
||||||
static const String lots = 'Lots';
|
static const String lots = 'Lots';
|
||||||
static const String lotId = 'Lot ID';
|
static const String lotId = 'Lot ID';
|
||||||
|
static const String lotTitle = 'Title';
|
||||||
|
static const String lotDescription = 'Description';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// AI-NOTE (for PR reviewer): This widget is currently unused — it was only used
|
// Currently unused — previously used by the mock sections in TenderDetailHeader,
|
||||||
// by the static mock sections in TenderDetailHeader, which are now commented
|
// which are now disabled. Kept for when those sections are re-enabled.
|
||||||
// out. It is intentionally KEPT for future use, not deleted. Safe to ignore as
|
|
||||||
// dead code for now.
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:tm_app/core/utils/size_config.dart';
|
import 'package:tm_app/core/utils/size_config.dart';
|
||||||
|
|||||||
@@ -4,19 +4,11 @@ import 'package:tm_app/core/theme/colors.dart';
|
|||||||
import 'package:tm_app/core/utils/size_config.dart';
|
import 'package:tm_app/core/utils/size_config.dart';
|
||||||
import 'package:tm_app/view_models/auth_view_model.dart';
|
import 'package:tm_app/view_models/auth_view_model.dart';
|
||||||
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
import 'package:tm_app/view_models/tender_detail_view_model.dart';
|
||||||
// AI-NOTE (for PR reviewer): These three imports are kept (commented) because
|
|
||||||
// the post-confirm navigation flow they supported (documents-completion routes
|
|
||||||
// + meeting-time modals) is preserved as commented-out code below, not deleted.
|
|
||||||
// The Accept flow now just shows a confirmation toast instead. Re-enable these
|
|
||||||
// imports if/when the navigation flow is reinstated.
|
|
||||||
// import 'package:tm_app/views/detail/widgets/meeting_time_bottom_sheet.dart';
|
|
||||||
// import 'package:tm_app/views/detail/widgets/meeting_time_dialog.dart';
|
|
||||||
import 'package:tm_app/views/shared/base_button.dart';
|
import 'package:tm_app/views/shared/base_button.dart';
|
||||||
import 'package:tm_app/views/shared/select_submission_bottom_sheet.dart';
|
import 'package:tm_app/views/shared/select_submission_bottom_sheet.dart';
|
||||||
|
|
||||||
import '../../../core/constants/tender_approval_status.dart';
|
import '../../../core/constants/tender_approval_status.dart';
|
||||||
import '../../../core/constants/tender_submision_mode.dart';
|
import '../../../core/constants/tender_submision_mode.dart';
|
||||||
// import '../../../core/routes/app_routes.dart';
|
|
||||||
import '../../../core/utils/app_toast.dart';
|
import '../../../core/utils/app_toast.dart';
|
||||||
import '../../../data/services/model/tender_data/tender_data.dart';
|
import '../../../data/services/model/tender_data/tender_data.dart';
|
||||||
import '../../shared/select_submission_dialog.dart';
|
import '../../shared/select_submission_dialog.dart';
|
||||||
@@ -73,37 +65,10 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
return SelectSubmissionBottomSheet(
|
return SelectSubmissionBottomSheet(
|
||||||
onConfirm: (String value) {
|
onConfirm: (String value) {
|
||||||
// AI-NOTE (for PR reviewer): New behavior —
|
// Confirming the submission modal shows a
|
||||||
// confirming the submission modal now just
|
// confirmation toast (no API call, no
|
||||||
// shows a confirmation toast (no API call, no
|
// navigation), per product request.
|
||||||
// navigation), per product request. The prior
|
|
||||||
// navigation flow is preserved below for future
|
|
||||||
// use and is intentionally NOT deleted.
|
|
||||||
_showSubmissionReceivedToast();
|
_showSubmissionReceivedToast();
|
||||||
// if (value ==
|
|
||||||
// TenderSubmissionMode.selfApply.value) {
|
|
||||||
// // viewModel.submitTenderApproval(
|
|
||||||
// // tenderId: widget.detail.id!,
|
|
||||||
// // submissionMode: value,
|
|
||||||
// // );
|
|
||||||
// CompletionOfDocumentsRouteData(
|
|
||||||
// tenderId: widget.detail.id!,
|
|
||||||
// ).push(context);
|
|
||||||
// } else if (value ==
|
|
||||||
// TenderSubmissionMode.partnership.value) {
|
|
||||||
// Future.delayed(Duration.zero, () {
|
|
||||||
// if (context.mounted) {
|
|
||||||
// showModalBottomSheet(
|
|
||||||
// context: context,
|
|
||||||
// builder: (_) {
|
|
||||||
// return MeetingTimeBottomSheet(
|
|
||||||
// onConfirm: (value) {},
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -147,10 +112,9 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): New helper added for the Accept flow. Shows a
|
// Shows a success toast confirming the submission was received. Guards on
|
||||||
// success toast confirming the submission was received. Guards on `mounted`
|
// `mounted` because it runs from a modal callback after the sheet/dialog is
|
||||||
// because it runs from a modal callback after the sheet/dialog is popped.
|
// popped, and uses the State's own (stable page) context, not the modal's.
|
||||||
// Uses the State's own context (stable page context), not the popped modal's.
|
|
||||||
void _showSubmissionReceivedToast() {
|
void _showSubmissionReceivedToast() {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -221,37 +185,7 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
return SelectSubmissionDialog(
|
return SelectSubmissionDialog(
|
||||||
onConfirm: (String value) {
|
onConfirm: (String value) {
|
||||||
// AI-NOTE (for PR reviewer): New behavior —
|
|
||||||
// toast only (no API/navigation). Old role-aware
|
|
||||||
// navigation preserved below for future use.
|
|
||||||
_showSubmissionReceivedToast();
|
_showSubmissionReceivedToast();
|
||||||
// if (value ==
|
|
||||||
// TenderSubmissionMode.selfApply.value) {
|
|
||||||
// if (context.mounted) {
|
|
||||||
// if (userRole == Role.analyst) {
|
|
||||||
// const FinalCompletionOfDocumentsRouteData()
|
|
||||||
// .push(context);
|
|
||||||
// } else {
|
|
||||||
// CompletionOfDocumentsRouteData(
|
|
||||||
// tenderId: widget.detail.id!,
|
|
||||||
// ).push(context);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } else if (value ==
|
|
||||||
// TenderSubmissionMode.partnership.value) {
|
|
||||||
// Future.delayed(Duration.zero, () {
|
|
||||||
// if (context.mounted) {
|
|
||||||
// showDialog(
|
|
||||||
// context: context,
|
|
||||||
// builder: (context) {
|
|
||||||
// return MeetingTimeDialog(
|
|
||||||
// onConfirm: (value) {},
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -261,34 +195,7 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
return SelectSubmissionBottomSheet(
|
return SelectSubmissionBottomSheet(
|
||||||
onConfirm: (String value) {
|
onConfirm: (String value) {
|
||||||
// AI-NOTE (for PR reviewer): New behavior —
|
|
||||||
// toast only (no API/navigation). Old flow
|
|
||||||
// (final-completion route + meeting-time
|
|
||||||
// bottom sheet) preserved below for future use.
|
|
||||||
_showSubmissionReceivedToast();
|
_showSubmissionReceivedToast();
|
||||||
// if (value ==
|
|
||||||
// TenderSubmissionMode.selfApply.value) {
|
|
||||||
// if (context.mounted) {
|
|
||||||
// const FinalCompletionOfDocumentsRouteData()
|
|
||||||
// .push(context);
|
|
||||||
// }
|
|
||||||
// } else if (value ==
|
|
||||||
// TenderSubmissionMode.partnership.value) {
|
|
||||||
// showDialog(
|
|
||||||
// context: context,
|
|
||||||
// builder: (context) {
|
|
||||||
// return MeetingTimeBottomSheet(
|
|
||||||
// onConfirm: (value) {
|
|
||||||
// AppToast.success(
|
|
||||||
// context,
|
|
||||||
// TenderDetailsStrings
|
|
||||||
// .meetingTimeSuccessfullySet,
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// AI-NOTE (for PR reviewer): This widget is currently unused — its usages on the
|
// Currently unused — its usages on the tender-details pages are disabled because
|
||||||
// tender-details pages are commented out (it renders a static/mock "profile
|
// it renders a static/mock "profile match + incomplete resume" card rather than
|
||||||
// match 75% + incomplete resume" card, not backend data). It is intentionally
|
// backend data. Kept for when the profile-match score is available from the API.
|
||||||
// KEPT for future use, not deleted. Safe to ignore as dead code for now.
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:tm_app/core/theme/colors.dart';
|
import 'package:tm_app/core/theme/colors.dart';
|
||||||
import 'package:tm_app/core/utils/size_config.dart';
|
import 'package:tm_app/core/utils/size_config.dart';
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ import 'package:tm_app/views/detail/widgets/tender_detail_info_section.dart';
|
|||||||
import '../../../core/constants/assets.dart';
|
import '../../../core/constants/assets.dart';
|
||||||
import '../../shared/flag.dart';
|
import '../../shared/flag.dart';
|
||||||
import '../strings/tender_details_strings.dart';
|
import '../strings/tender_details_strings.dart';
|
||||||
// AI-NOTE (for PR reviewer): Import is intentionally kept but commented out.
|
|
||||||
// The static/mock sections it supports (key risks, winning chance, project id,
|
|
||||||
// service/requirements/background dropdowns) were disabled — NOT removed —
|
|
||||||
// because they were hard-coded placeholders, not backend data. Re-enable this
|
|
||||||
// import together with those sections once the API provides that data.
|
|
||||||
// import 'detail_drop_down.dart';
|
|
||||||
|
|
||||||
class TenderDetailHeader extends StatelessWidget {
|
class TenderDetailHeader extends StatelessWidget {
|
||||||
final bool isScreenBig;
|
final bool isScreenBig;
|
||||||
@@ -46,27 +40,12 @@ class TenderDetailHeader extends StatelessWidget {
|
|||||||
SizedBox(height: 16.0.h()),
|
SizedBox(height: 16.0.h()),
|
||||||
_locationBudgetRow(),
|
_locationBudgetRow(),
|
||||||
SizedBox(height: 24.0.h()),
|
SizedBox(height: 24.0.h()),
|
||||||
// AI-NOTE (for PR reviewer): The following mock sections are disabled
|
|
||||||
// on purpose (kept for future use). They render hard-coded values, not
|
|
||||||
// backend data, so they are commented out rather than deleted.
|
|
||||||
// _projectId(),
|
|
||||||
// SizedBox(height: 12.0.h()),
|
|
||||||
// _winningChanceAndDificulty(),
|
|
||||||
// SizedBox(height: 24.0.h()),
|
|
||||||
// _keyRisksDropDown(),
|
|
||||||
// SizedBox(height: 24.0.h()),
|
|
||||||
Divider(color: AppColors.grey20),
|
Divider(color: AppColors.grey20),
|
||||||
TenderDetailInfoSection(isScreenBig: isScreenBig, detail: detail),
|
TenderDetailInfoSection(isScreenBig: isScreenBig, detail: detail),
|
||||||
SizedBox(height: 24.0.h()),
|
SizedBox(height: 24.0.h()),
|
||||||
_overViewTitle(),
|
_overViewTitle(),
|
||||||
SizedBox(height: 12.0.h()),
|
SizedBox(height: 12.0.h()),
|
||||||
_descriptionText(),
|
_descriptionText(),
|
||||||
// AI-NOTE (for PR reviewer): Mock dropdowns disabled (kept for future).
|
|
||||||
// _serviceDropDown(),
|
|
||||||
// SizedBox(height: 24.0.h()),
|
|
||||||
// _requirementsDropDown(),
|
|
||||||
// SizedBox(height: 24.0.h()),
|
|
||||||
// _backgroundDropDown(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -94,103 +73,6 @@ class TenderDetailHeader extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): The mock widget builders below are intentionally
|
|
||||||
// preserved as commented-out code. They produce static placeholder content
|
|
||||||
// (not backend-driven) and will be reinstated once the API supplies the
|
|
||||||
// corresponding fields. Do not flag them as dead code — keep as-is.
|
|
||||||
/*
|
|
||||||
Widget _serviceDropDown() =>
|
|
||||||
const DetailDropDown(title: 'Servive/Product', items: []);
|
|
||||||
|
|
||||||
Widget _backgroundDropDown() =>
|
|
||||||
const DetailDropDown(title: 'Background', items: []);
|
|
||||||
|
|
||||||
Widget _requirementsDropDown() {
|
|
||||||
return const DetailDropDown(
|
|
||||||
title: 'Requirements (Summary)',
|
|
||||||
items: [
|
|
||||||
'Market consultation for selecting and procuring a Customer Data Platform (CDP) to unify student data from multiple sources and improve personalization and marketing.',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _keyRisksDropDown() {
|
|
||||||
return const DetailDropDown(
|
|
||||||
title: 'Key risks & dealbreakers',
|
|
||||||
items: [
|
|
||||||
'ISO 27001:2022 likely mandatory for future tender.',
|
|
||||||
'Lack of past performance — public sector values references.',
|
|
||||||
'Implementation & ongoing support required (1–5 person team expectation).',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _winningChanceAndDificulty() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 24.0.h(),
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 16.0.w(),
|
|
||||||
vertical: 4.0.h(),
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.blue0,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Winning chance: <5%',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.blue10,
|
|
||||||
fontSize: 12.0.sp(),
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 12.0.w()),
|
|
||||||
Container(
|
|
||||||
height: 24.0.h(),
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 16.0.w(),
|
|
||||||
vertical: 4.0.h(),
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.blue0,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Difficulty 2/5',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.blue10,
|
|
||||||
fontSize: 12.0.sp(),
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _projectId() {
|
|
||||||
return Container(
|
|
||||||
height: 24.0.h(),
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.0.w(), vertical: 4.0.h()),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.blue0,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Project ID 6243372025NLD',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.blue10,
|
|
||||||
fontSize: 12.0.sp(),
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
Widget _locationBudgetRow() {
|
Widget _locationBudgetRow() {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import 'package:tm_app/views/detail/strings/tender_details_strings.dart';
|
|||||||
import 'package:tm_app/views/detail/widgets/deadline_item.dart';
|
import 'package:tm_app/views/detail/widgets/deadline_item.dart';
|
||||||
import 'package:tm_app/views/detail/widgets/info_item.dart';
|
import 'package:tm_app/views/detail/widgets/info_item.dart';
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): This widget was rewritten to render ONLY the
|
// Renders only the backend-driven fields for the tender-details page (notice
|
||||||
// backend-driven fields requested for the tender-details page (notice type,
|
// type, form type, languages, issue/publication dates, procedure, value,
|
||||||
// form type, languages, issue/publication dates, procedure, value, duration,
|
// duration, deadlines, buyer org/address, lots, status, ids), using
|
||||||
// deadlines, buyer org/address, lots, status, ids). Design is unchanged — it
|
// InfoItem/DeadlineItem. The _infoItem() helper returns an empty list when a
|
||||||
// still uses InfoItem/DeadlineItem. The _infoItem() helper returns an empty
|
// value is null/blank so empty fields don't render as blank rows (this is why
|
||||||
// list when a value is null/blank so empty fields don't render as blank rows
|
// each entry is spread with `...`).
|
||||||
// (this is why each entry is spread with `...`). The old hard-coded
|
|
||||||
// "Reference Number" row was dropped because it isn't in the requested set.
|
|
||||||
class TenderDetailInfoSection extends StatelessWidget {
|
class TenderDetailInfoSection extends StatelessWidget {
|
||||||
final bool isScreenBig;
|
final bool isScreenBig;
|
||||||
final TenderData detail;
|
final TenderData detail;
|
||||||
@@ -112,8 +110,8 @@ class TenderDetailInfoSection extends StatelessWidget {
|
|||||||
SizedBox(height: 8.0.h()),
|
SizedBox(height: 8.0.h()),
|
||||||
for (final lot in lots) ...[
|
for (final lot in lots) ...[
|
||||||
..._infoItem(TenderDetailsStrings.lotId, lot.lotId),
|
..._infoItem(TenderDetailsStrings.lotId, lot.lotId),
|
||||||
..._infoItem('Title', lot.title),
|
..._infoItem(TenderDetailsStrings.lotTitle, lot.title),
|
||||||
..._infoItem('Description', lot.description),
|
..._infoItem(TenderDetailsStrings.lotDescription, lot.description),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,30 +87,9 @@ class DesktopNavigationWidget extends StatelessWidget {
|
|||||||
iconPath: AssetsManager.tenders,
|
iconPath: AssetsManager.tenders,
|
||||||
activeIconPath: AssetsManager.tendersActive,
|
activeIconPath: AssetsManager.tendersActive,
|
||||||
),
|
),
|
||||||
// AI-NOTE (for PR reviewer): Board tab disabled (commented, not
|
// Board tab is intentionally hidden from desktop navigation. The
|
||||||
// removed) to hide the Board page from desktop navigation per request.
|
// Board route/page itself remains intact; the nav item (at tab index
|
||||||
// The Board route/page itself is left intact; re-enable this nav item
|
// 2) can be restored from git history when the feature is re-enabled.
|
||||||
// to bring the tab back. Note the tab indices of later items are
|
|
||||||
// unchanged because this block is fully commented out.
|
|
||||||
// const SizedBox(width: 24),
|
|
||||||
// _navigationItem(
|
|
||||||
// context: context,
|
|
||||||
// text: TendersStrings.board,
|
|
||||||
// isActive: currentIndex == 2,
|
|
||||||
// onTap: () {
|
|
||||||
// if (currentIndex == 2) {
|
|
||||||
// return;
|
|
||||||
// } else {
|
|
||||||
// Router.neglect(
|
|
||||||
// context,
|
|
||||||
// () => const BoardRouteData().go(context),
|
|
||||||
// );
|
|
||||||
// // context.read<TendersViewModel>().getTenders();
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// iconPath: AssetsManager.board,
|
|
||||||
// activeIconPath: AssetsManager.boardActive,
|
|
||||||
// ),
|
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
_navigationItem(
|
_navigationItem(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ import '../../../core/constants/assets.dart';
|
|||||||
import '../../../core/theme/colors.dart';
|
import '../../../core/theme/colors.dart';
|
||||||
import '../../../core/utils/date_utils.dart';
|
import '../../../core/utils/date_utils.dart';
|
||||||
import '../../../core/utils/size_config.dart';
|
import '../../../core/utils/size_config.dart';
|
||||||
// AI-NOTE (for PR reviewer): Import kept (commented) because the match-percentage
|
|
||||||
// section it feeds (TenderDetailsStrings.tenderMatchProfile) is disabled, not
|
|
||||||
// removed. Re-enable alongside _matchPercentage()/_progressBar() below when the
|
|
||||||
// "match with profile" value comes from the backend.
|
|
||||||
// import '../../detail/strings/tender_details_strings.dart';
|
|
||||||
import '../../shared/flag.dart';
|
import '../../shared/flag.dart';
|
||||||
import '../strings/tenders_strings.dart';
|
import '../strings/tenders_strings.dart';
|
||||||
import 'tender_action_buttons_row.dart';
|
import 'tender_action_buttons_row.dart';
|
||||||
@@ -87,18 +82,6 @@ class TenderCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// AI-NOTE (for PR reviewer): The "Match with your profile"
|
|
||||||
// label, the 45% value and the progress bar are disabled on
|
|
||||||
// purpose (kept for future use). They show a hard-coded 45%,
|
|
||||||
// not a backend value, so they are commented out, not deleted.
|
|
||||||
// // Match percentage
|
|
||||||
// _matchPercentage(),
|
|
||||||
// SizedBox(height: isDesktop ? 4.0.h() : 8.0.h()),
|
|
||||||
//
|
|
||||||
// // Progress bar
|
|
||||||
// _progressBar(),
|
|
||||||
// SizedBox(height: isDesktop ? 8.0.h() : 16.0.h()),
|
|
||||||
|
|
||||||
// Location and apply button
|
// Location and apply button
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -251,57 +234,6 @@ class TenderCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): _matchPercentage() and _progressBar() are
|
|
||||||
// preserved as commented-out code. Both render a hard-coded 45% placeholder
|
|
||||||
// (no backend source yet), so they are disabled rather than removed. Keep them
|
|
||||||
// for when the profile-match score becomes available from the API.
|
|
||||||
/*
|
|
||||||
Widget _matchPercentage() {
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
TenderDetailsStrings.tenderMatchProfile,
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.grey80,
|
|
||||||
fontSize: isDesktop ? 12.0.sp() : 14.0.sp(),
|
|
||||||
fontWeight: isDesktop ? FontWeight.w400 : FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${45}%',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.secondaryTextColor,
|
|
||||||
fontSize: isDesktop ? 12.0.sp() : 16.0.sp(),
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _progressBar() {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 4.0.h(),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.grey20,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
child: FractionallySizedBox(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
widthFactor: (45) / 100,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.secondary50,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
Widget _locationInfo() {
|
Widget _locationInfo() {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ void main() {
|
|||||||
expect(feedback.tender?.buyerOrganization?.name, 'Buyer Org');
|
expect(feedback.tender?.buyerOrganization?.name, 'Buyer Org');
|
||||||
});
|
});
|
||||||
|
|
||||||
// AI-NOTE (for PR reviewer): These TenderData/Organization literals were
|
|
||||||
// updated only to satisfy the new required fields added to those models
|
|
||||||
// (notice_type_code, form_type, issue_date/time, lots, official_languages,
|
|
||||||
// company_id, address). Test assertions/behavior are unchanged.
|
|
||||||
test('toJson should return valid JSON map', () {
|
test('toJson should return valid JSON map', () {
|
||||||
final tender = const TenderData(
|
final tender = const TenderData(
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tm_app/data/services/model/lot/lot.dart';
|
||||||
|
import 'package:tm_app/data/services/model/organization/organization.dart';
|
||||||
|
import 'package:tm_app/data/services/model/organization/organization_address.dart';
|
||||||
|
import 'package:tm_app/data/services/model/tender_data/tender_data.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Lot', () {
|
||||||
|
test('fromJson maps lot_id/title/description', () {
|
||||||
|
final lot = Lot.fromJson(const {
|
||||||
|
'lot_id': 'LOT-1',
|
||||||
|
'title': 'Lot title',
|
||||||
|
'description': 'Lot description',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lot.lotId, 'LOT-1');
|
||||||
|
expect(lot.title, 'Lot title');
|
||||||
|
expect(lot.description, 'Lot description');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toJson round-trips back to the same values', () {
|
||||||
|
const lot = Lot(lotId: 'LOT-2', title: 'T', description: 'D');
|
||||||
|
|
||||||
|
expect(Lot.fromJson(lot.toJson()), lot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing fields parse as null (resilient to partial payloads)', () {
|
||||||
|
final lot = Lot.fromJson(const {});
|
||||||
|
|
||||||
|
expect(lot.lotId, isNull);
|
||||||
|
expect(lot.title, isNull);
|
||||||
|
expect(lot.description, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('OrganizationAddress', () {
|
||||||
|
test('fromJson maps city_name/country_code', () {
|
||||||
|
final address = OrganizationAddress.fromJson(const {
|
||||||
|
'city_name': 'Amsterdam',
|
||||||
|
'country_code': 'NL',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(address.cityName, 'Amsterdam');
|
||||||
|
expect(address.countryCode, 'NL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toJson round-trips back to the same values', () {
|
||||||
|
const address = OrganizationAddress(
|
||||||
|
cityName: 'Berlin',
|
||||||
|
countryCode: 'DE',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(OrganizationAddress.fromJson(address.toJson()), address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested under Organization.fromJson', () {
|
||||||
|
final org = Organization.fromJson(const {
|
||||||
|
'name': 'Buyer Org',
|
||||||
|
'company_id': 'COMP-1',
|
||||||
|
'address': {'city_name': 'Paris', 'country_code': 'FR'},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(org.name, 'Buyer Org');
|
||||||
|
expect(org.companyId, 'COMP-1');
|
||||||
|
expect(org.address?.cityName, 'Paris');
|
||||||
|
expect(org.address?.countryCode, 'FR');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TenderData new fields', () {
|
||||||
|
Map<String, Object?> baseJson({
|
||||||
|
Object? issueDate,
|
||||||
|
Object? issueTime,
|
||||||
|
}) => {
|
||||||
|
'success': true,
|
||||||
|
'message': 'ok',
|
||||||
|
'id': 'TND-1',
|
||||||
|
'tender_id': 'TND-1',
|
||||||
|
'notice_publication_id': 'NP-1',
|
||||||
|
'notice_type_code': 'NT-1',
|
||||||
|
'form_type': 'open',
|
||||||
|
'notice_language_code': 'en',
|
||||||
|
'issue_date': issueDate,
|
||||||
|
'issue_time': issueTime,
|
||||||
|
'publication_date': 1700000000,
|
||||||
|
'title': 'Title',
|
||||||
|
'description': 'Desc',
|
||||||
|
'procurement_type_code': 'PT-1',
|
||||||
|
'procedure_code': 'PRC-1',
|
||||||
|
'estimated_value': 1000,
|
||||||
|
'currency': 'EUR',
|
||||||
|
'duration': '12',
|
||||||
|
'tender_deadline': 1710000000,
|
||||||
|
'submission_deadline': 1710000000,
|
||||||
|
'application_deadline': 1710000000,
|
||||||
|
'submission_url': 'https://example.com',
|
||||||
|
'country_code': 'NL',
|
||||||
|
'duration_unit': 'month',
|
||||||
|
'buyer_organization': {
|
||||||
|
'name': 'Buyer Org',
|
||||||
|
'company_id': 'COMP-1',
|
||||||
|
'address': {'city_name': 'Amsterdam', 'country_code': 'NL'},
|
||||||
|
},
|
||||||
|
'lots': [
|
||||||
|
{'lot_id': 'LOT-1', 'title': 'Lot title', 'description': 'Lot desc'},
|
||||||
|
],
|
||||||
|
'official_languages': ['en', 'nl'],
|
||||||
|
'status': 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('parses notice/form/language, lots and official_languages', () {
|
||||||
|
final tender = TenderData.fromJson(baseJson(
|
||||||
|
issueDate: 1700000000,
|
||||||
|
issueTime: 1700003600,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(tender.noticeTypeCode, 'NT-1');
|
||||||
|
expect(tender.formType, 'open');
|
||||||
|
expect(tender.noticeLanguageCode, 'en');
|
||||||
|
expect(tender.officialLanguages, ['en', 'nl']);
|
||||||
|
expect(tender.lots, hasLength(1));
|
||||||
|
expect(tender.lots?.first.lotId, 'LOT-1');
|
||||||
|
expect(tender.buyerOrganization?.address?.cityName, 'Amsterdam');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue_date/issue_time accept epoch seconds', () {
|
||||||
|
final tender = TenderData.fromJson(baseJson(
|
||||||
|
issueDate: 1700000000,
|
||||||
|
issueTime: 1700003600,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(tender.issueDate, 1700000000);
|
||||||
|
expect(tender.issueTime, 1700003600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('issue_date/issue_time accept millisecond and string inputs', () {
|
||||||
|
final tender = TenderData.fromJson(baseJson(
|
||||||
|
issueDate: '1700000000',
|
||||||
|
issueTime: 1700000000000,
|
||||||
|
));
|
||||||
|
|
||||||
|
// unixTimestampFromJson normalizes the raw value; the >1e10 ms value is
|
||||||
|
// kept as-is here (timeConvertor downscales it at display time).
|
||||||
|
expect(tender.issueDate, 1700000000);
|
||||||
|
expect(tender.issueTime, 1700000000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-positive or missing issue_date/issue_time become null', () {
|
||||||
|
final tender = TenderData.fromJson(baseJson(
|
||||||
|
issueDate: 0,
|
||||||
|
issueTime: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(tender.issueDate, isNull);
|
||||||
|
expect(tender.issueTime, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user