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:
AmirReza Jamali
2026-06-05 19:42:09 +03:30
parent dde66521f6
commit 86ed7bc665
17 changed files with 204 additions and 375 deletions
+3 -3
View File
@@ -5,9 +5,9 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'lot.freezed.dart';
part 'lot.g.dart';
// AI-NOTE (for PR reviewer): New model for entries in the tender-details
// `lots` array. Only lot_id, title and description are mapped — the fields the
// tender-details "Lots" section renders. Add more fields here if the UI grows.
// Model for entries in the tender-details `lots` array. Only lot_id, title and
// description are mapped — the fields the tender-details "Lots" section renders.
// Add more fields here if the UI grows.
@freezed
abstract class Lot with _$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.g.dart';
// AI-NOTE (for PR reviewer): Added company_id and a nested address
// (OrganizationAddress) to surface buyer details on the tender-details page.
// A dedicated OrganizationAddress type is used instead of the existing
// `Address` model because the buyer address shape differs (city_name +
// country_code only) and is unrelated to the user-profile Address.
// company_id and a nested address (OrganizationAddress) surface buyer details
// on the tender-details page. A dedicated OrganizationAddress type is used
// instead of the existing `Address` model because the buyer address shape
// differs (city_name + country_code only) and is unrelated to the user-profile
// Address.
@freezed
abstract class Organization with _$Organization {
const factory Organization({
@@ -5,10 +5,10 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'organization_address.freezed.dart';
part 'organization_address.g.dart';
// AI-NOTE (for PR reviewer): New model for the buyer_organization.address
// object from the tender-details API. Kept separate from the existing profile
// `Address` model because the shapes differ (this one only needs city_name and
// country_code). Only the fields the UI shows are mapped.
// Model for the buyer_organization.address object from the tender-details API.
// Kept separate from the existing profile `Address` model because the shapes
// differ (this one only needs city_name and country_code). Only the fields the
// UI shows are mapped.
@freezed
abstract class OrganizationAddress with _$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.g.dart';
// AI-NOTE (for PR reviewer): New fields were added to mirror the
// /tenders/details/:id response so the tender-details page can show them:
// notice_type_code, form_type, notice_language_code, issue_date, issue_time,
// lots, official_languages. issue_date/issue_time reuse unixTimestampFromJson
// (epoch seconds, tolerant of ms/string) like the other timestamp fields. All
// fields are nullable to stay resilient to partial API payloads. Freezed/json
// codegen (.freezed.dart/.g.dart) was regenerated for these.
// Fields mirror the /tenders/details/:id response so the tender-details page
// can show them: notice_type_code, form_type, notice_language_code, issue_date,
// issue_time, lots, official_languages. issue_date/issue_time reuse
// unixTimestampFromJson (epoch seconds, tolerant of ms/string) like the other
// timestamp fields. All fields are nullable to stay resilient to partial API
// payloads.
@freezed
abstract class TenderData with _$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/view_models/tender_detail_view_model.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 '../../../core/constants/tender_approval_status.dart';
@@ -123,10 +120,6 @@ class _TenderDetailDesktopPageState extends State<TenderDetailDesktopPage> {
detail: detail,
),
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(
isScreenBig: true,
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/views/detail/strings/tender_details_strings.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/shared/tender_app_bar.dart';
@@ -114,10 +111,6 @@ class _TenderDetailMobilePageState extends State<TenderDetailMobilePage> {
children: [
TenderDetailHeader(isScreenBig: false, detail: detail),
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(
isScreenBig: false,
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/view_models/tender_detail_view_model.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/shared/tablet_navigation_widget.dart';
@@ -125,10 +122,6 @@ class _TenderDetailTabletPageState extends State<TenderDetailTabletPage> {
SizedBox(height: 28.0.h()),
TenderDetailHeader(isScreenBig: true, detail: detail),
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(
isScreenBig: true,
detail: detail,
@@ -42,13 +42,13 @@ class TenderDetailsStrings {
static const String requestSuccessfullyRegistered =
'Your request has been successfully registered.';
static const String confirmAndClose = 'Confirm and close';
// AI-NOTE (for PR reviewer): Formal toast copy shown after the user confirms
// the Accept/submission modal (see tender_detail_action.dart).
// Toast copy shown after the user confirms the Accept/submission modal
// (see tender_detail_action.dart).
static const String submissionReceived =
'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
// tender-details info section (one per backend field rendered there).
// Field labels for the tender-details info section (one per backend field
// rendered there).
// Tender detail field labels
static const String noticeTypeCode = 'Notice Type';
static const String formType = 'Form Type';
@@ -71,4 +71,6 @@ class TenderDetailsStrings {
static const String tenderId = 'Tender ID';
static const String lots = 'Lots';
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
// by the static mock sections in TenderDetailHeader, which are now commented
// out. It is intentionally KEPT for future use, not deleted. Safe to ignore as
// dead code for now.
// Currently unused — previously used by the mock sections in TenderDetailHeader,
// which are now disabled. Kept for when those sections are re-enabled.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.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/view_models/auth_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/select_submission_bottom_sheet.dart';
import '../../../core/constants/tender_approval_status.dart';
import '../../../core/constants/tender_submision_mode.dart';
// import '../../../core/routes/app_routes.dart';
import '../../../core/utils/app_toast.dart';
import '../../../data/services/model/tender_data/tender_data.dart';
import '../../shared/select_submission_dialog.dart';
@@ -73,37 +65,10 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
builder: (context) {
return SelectSubmissionBottomSheet(
onConfirm: (String value) {
// AI-NOTE (for PR reviewer): New behavior —
// confirming the submission modal now just
// shows a confirmation toast (no API call, no
// navigation), per product request. The prior
// navigation flow is preserved below for future
// use and is intentionally NOT deleted.
// Confirming the submission modal shows a
// confirmation toast (no API call, no
// navigation), per product request.
_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
// success toast confirming the submission was received. Guards on `mounted`
// because it runs from a modal callback after the sheet/dialog is popped.
// Uses the State's own context (stable page context), not the popped modal's.
// Shows a success toast confirming the submission was received. Guards on
// `mounted` because it runs from a modal callback after the sheet/dialog is
// popped, and uses the State's own (stable page) context, not the modal's.
void _showSubmissionReceivedToast() {
if (!mounted) {
return;
@@ -221,37 +185,7 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
builder: (context) {
return SelectSubmissionDialog(
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();
// 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) {
return SelectSubmissionBottomSheet(
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();
// 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
// tender-details pages are commented out (it renders a static/mock "profile
// match 75% + incomplete resume" card, not backend data). It is intentionally
// KEPT for future use, not deleted. Safe to ignore as dead code for now.
// Currently unused — its usages on the tender-details pages are disabled because
// it renders a static/mock "profile match + incomplete resume" card rather than
// backend data. Kept for when the profile-match score is available from the API.
import 'package:flutter/material.dart';
import 'package:tm_app/core/theme/colors.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 '../../shared/flag.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 {
final bool isScreenBig;
@@ -46,27 +40,12 @@ class TenderDetailHeader extends StatelessWidget {
SizedBox(height: 16.0.h()),
_locationBudgetRow(),
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),
TenderDetailInfoSection(isScreenBig: isScreenBig, detail: detail),
SizedBox(height: 24.0.h()),
_overViewTitle(),
SizedBox(height: 12.0.h()),
_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 (15 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() {
return Row(
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/info_item.dart';
// AI-NOTE (for PR reviewer): This widget was rewritten to render ONLY the
// backend-driven fields requested for the tender-details page (notice type,
// form type, languages, issue/publication dates, procedure, value, duration,
// deadlines, buyer org/address, lots, status, ids). Design is unchanged — it
// still uses InfoItem/DeadlineItem. The _infoItem() helper returns an empty
// list when a value is null/blank so empty fields don't render as blank rows
// (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.
// Renders only the backend-driven fields for the tender-details page (notice
// type, form type, languages, issue/publication dates, procedure, value,
// duration, deadlines, buyer org/address, lots, status, ids), using
// InfoItem/DeadlineItem. The _infoItem() helper returns an empty list when a
// value is null/blank so empty fields don't render as blank rows (this is why
// each entry is spread with `...`).
class TenderDetailInfoSection extends StatelessWidget {
final bool isScreenBig;
final TenderData detail;
@@ -112,8 +110,8 @@ class TenderDetailInfoSection extends StatelessWidget {
SizedBox(height: 8.0.h()),
for (final lot in lots) ...[
..._infoItem(TenderDetailsStrings.lotId, lot.lotId),
..._infoItem('Title', lot.title),
..._infoItem('Description', lot.description),
..._infoItem(TenderDetailsStrings.lotTitle, lot.title),
..._infoItem(TenderDetailsStrings.lotDescription, lot.description),
],
];
}
@@ -87,30 +87,9 @@ class DesktopNavigationWidget extends StatelessWidget {
iconPath: AssetsManager.tenders,
activeIconPath: AssetsManager.tendersActive,
),
// AI-NOTE (for PR reviewer): Board tab disabled (commented, not
// removed) to hide the Board page from desktop navigation per request.
// The Board route/page itself is left intact; re-enable this nav item
// 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,
// ),
// Board tab is intentionally hidden from desktop navigation. The
// Board route/page itself remains intact; the nav item (at tab index
// 2) can be restored from git history when the feature is re-enabled.
const SizedBox(width: 24),
_navigationItem(
context: context,
@@ -8,11 +8,6 @@ import '../../../core/constants/assets.dart';
import '../../../core/theme/colors.dart';
import '../../../core/utils/date_utils.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 '../strings/tenders_strings.dart';
import 'tender_action_buttons_row.dart';
@@ -87,18 +82,6 @@ class TenderCard extends StatelessWidget {
),
child: Column(
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
Row(
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() {
return Expanded(
child: Row(
-4
View File
@@ -51,10 +51,6 @@ void main() {
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', () {
final tender = const TenderData(
success: true,
+158
View File
@@ -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);
});
});
}