Add Docker support and enhance data models for tender details

- Introduced a .dockerignore file to optimize Docker builds by excluding unnecessary files.
- Updated the Dockerfile to use a Flutter base image, enabling web builds for the application.
- Added new data models for `Lot` and `OrganizationAddress` to represent tender details more accurately.
- Enhanced the `Organization` model to include `companyId` and a nested `address` field.
- Updated the `TenderData` model to include new fields such as `noticeTypeCode`, `formType`, and `lots`, reflecting the API response structure.
- Regenerated necessary code for data models to ensure compatibility with the updated structures.
This commit is contained in:
AmirReza Jamali
2026-06-03 13:32:42 +03:30
parent 83b77c05ef
commit dde66521f6
35 changed files with 1362 additions and 659 deletions
@@ -1,3 +1,7 @@
// 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.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:tm_app/core/utils/size_config.dart';
@@ -4,14 +4,19 @@ 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';
import 'package:tm_app/views/detail/widgets/meeting_time_bottom_sheet.dart';
import 'package:tm_app/views/detail/widgets/meeting_time_dialog.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/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';
@@ -68,38 +73,37 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
builder: (context) {
return SelectSubmissionBottomSheet(
onConfirm: (String value) {
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) {
// AppToast.success(
// context,
// TenderDetailsStrings
// .meetingTimeSuccessfullySet,
// );
},
);
},
);
}
});
}
// 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.
_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) {},
// );
// },
// );
// }
// });
// }
},
);
},
@@ -143,6 +147,17 @@ 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.
void _showSubmissionReceivedToast() {
if (!mounted) {
return;
}
AppToast.success(context, TenderDetailsStrings.submissionReceived);
}
Row _webActions(Role userRole) {
return Row(
children: [
@@ -206,33 +221,37 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
builder: (context) {
return SelectSubmissionDialog(
onConfirm: (String value) {
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) {},
);
},
);
}
});
}
// 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) {},
// );
// },
// );
// }
// });
// }
},
);
},
@@ -242,41 +261,34 @@ class _TenderDetailActionsState extends State<TenderDetailActions> {
builder: (context) {
return SelectSubmissionBottomSheet(
onConfirm: (String value) {
if (value ==
TenderSubmissionMode.selfApply.value) {
// viewModel.submitTenderApproval(
// tenderId: widget.detail.id!,
// submissionMode: 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,
);
},
);
// return MeetingTimeDialog(
// onConfirm: (String value) {
// if (context.mounted) {
// const CompletionOfDocumentsRouteData()
// .push(context);
// }
// },
// );
},
);
}
// 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,3 +1,7 @@
// 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.
import 'package:flutter/material.dart';
import 'package:tm_app/core/theme/colors.dart';
import 'package:tm_app/core/utils/size_config.dart';
@@ -9,7 +9,12 @@ 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';
import 'detail_drop_down.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;
@@ -41,44 +46,32 @@ class TenderDetailHeader extends StatelessWidget {
SizedBox(height: 16.0.h()),
_locationBudgetRow(),
SizedBox(height: 24.0.h()),
_projectId(),
SizedBox(height: 12.0.h()),
_winningChanceAndDificulty(),
SizedBox(height: 24.0.h()),
_keyRisksDropDown(),
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(),
SizedBox(height: 24.0.h()),
_serviceDropDown(),
SizedBox(height: 24.0.h()),
_requirementsDropDown(),
SizedBox(height: 24.0.h()),
_backgroundDropDown(),
// AI-NOTE (for PR reviewer): Mock dropdowns disabled (kept for future).
// _serviceDropDown(),
// SizedBox(height: 24.0.h()),
// _requirementsDropDown(),
// SizedBox(height: 24.0.h()),
// _backgroundDropDown(),
],
),
);
}
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 _descriptionText() {
return Text(
detail.description ?? '',
@@ -101,6 +94,26 @@ 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',
@@ -146,7 +159,7 @@ class TenderDetailHeader extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Difficulty 2/5',
'Difficulty 2/5',
style: TextStyle(
color: AppColors.blue10,
fontSize: 12.0.sp(),
@@ -167,7 +180,7 @@ class TenderDetailHeader extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Project ID 6243372025NLD',
'Project ID 6243372025NLD',
style: TextStyle(
color: AppColors.blue10,
fontSize: 12.0.sp(),
@@ -176,6 +189,7 @@ class TenderDetailHeader extends StatelessWidget {
),
);
}
*/
Widget _locationBudgetRow() {
return Row(
@@ -1,10 +1,21 @@
import 'package:flutter/material.dart';
import 'package:tm_app/core/theme/colors.dart';
import 'package:tm_app/core/utils/date_utils.dart';
import 'package:tm_app/core/utils/price_extension.dart';
import 'package:tm_app/core/utils/size_config.dart';
import 'package:tm_app/data/services/model/tender_data/tender_data.dart';
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.
class TenderDetailInfoSection extends StatelessWidget {
final bool isScreenBig;
final TenderData detail;
@@ -17,24 +28,114 @@ class TenderDetailInfoSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final buyer = detail.buyerOrganization;
final address = buyer?.address;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DeadlineItem(detail: detail, isScreenBig: isScreenBig),
InfoItem(
title: TenderDetailsStrings.tenderClientLabel,
value: detail.buyerOrganization!.name ?? '',
..._infoItem(TenderDetailsStrings.noticeTypeCode, detail.noticeTypeCode),
..._infoItem(TenderDetailsStrings.formType, detail.formType),
..._infoItem(
TenderDetailsStrings.noticeLanguageCode,
detail.noticeLanguageCode,
),
InfoItem(
title: TenderDetailsStrings.estimatedValue,
value:
'${detail.estimatedValue?.formattedPrice} ${detail.currency ?? ''}',
..._infoItem(
TenderDetailsStrings.issueDate,
_date(detail.issueDate),
),
InfoItem(
title: TenderDetailsStrings.tenderReferenceNumberLabel,
value: detail.noticePublicationId ?? '',
..._infoItem(
TenderDetailsStrings.issueTime,
_dateTime(detail.issueTime),
),
..._infoItem(TenderDetailsStrings.procedureCode, detail.procedureCode),
..._infoItem(
TenderDetailsStrings.estimatedValue,
detail.estimatedValue == null
? null
: '${detail.estimatedValue?.formattedPrice} ${detail.currency ?? ''}',
),
..._infoItem(TenderDetailsStrings.durationLabel, _duration()),
..._infoItem(
TenderDetailsStrings.publicationDate,
_date(detail.publicationDate),
),
..._infoItem(
TenderDetailsStrings.tenderDeadlineDate,
_date(detail.tenderDeadline),
),
..._infoItem(TenderDetailsStrings.countryCode, detail.countryCode),
..._infoItem(TenderDetailsStrings.buyerName, buyer?.name),
..._infoItem(TenderDetailsStrings.companyId, buyer?.companyId),
..._infoItem(TenderDetailsStrings.buyerCity, address?.cityName),
..._infoItem(TenderDetailsStrings.buyerCountry, address?.countryCode),
..._infoItem(
TenderDetailsStrings.officialLanguages,
(detail.officialLanguages == null ||
detail.officialLanguages!.isEmpty)
? null
: detail.officialLanguages!.join(', '),
),
..._infoItem(TenderDetailsStrings.status, detail.status),
..._infoItem(TenderDetailsStrings.tenderId, detail.tenderId),
..._lotsSection(),
],
);
}
/// Returns an [InfoItem] only when [value] has content, so empty fields are
/// not rendered as blank rows.
List<Widget> _infoItem(String title, String? value) {
if (value == null || value.trim().isEmpty) {
return const [];
}
return [InfoItem(title: title, value: value)];
}
List<Widget> _lotsSection() {
final lots = detail.lots;
if (lots == null || lots.isEmpty) {
return const [];
}
return [
SizedBox(height: 8.0.h()),
Text(
TenderDetailsStrings.lots,
style: TextStyle(
color: AppColors.grey80,
fontWeight: FontWeight.w600,
fontSize: 16.0.sp(),
),
),
SizedBox(height: 8.0.h()),
for (final lot in lots) ...[
..._infoItem(TenderDetailsStrings.lotId, lot.lotId),
..._infoItem('Title', lot.title),
..._infoItem('Description', lot.description),
],
];
}
String? _duration() {
final duration = detail.duration?.trim() ?? '';
final unit = detail.durationUnit?.trim() ?? '';
final combined = '$duration $unit'.trim();
return combined.isEmpty ? null : combined;
}
String? _date(int? unix) {
if (unix == null) {
return null;
}
return timeConvertor(unix);
}
String? _dateTime(int? unix) {
if (unix == null) {
return null;
}
return timeConvertor(unix, hasTime: true);
}
}