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:
@@ -4,7 +4,9 @@ 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';
|
||||
import 'package:tm_app/views/detail/widgets/tender_detail_card.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';
|
||||
@@ -121,8 +123,10 @@ class _TenderDetailDesktopPageState extends State<TenderDetailDesktopPage> {
|
||||
detail: detail,
|
||||
),
|
||||
SizedBox(height: 32.0.h()),
|
||||
TenderDetailCard(detail: detail),
|
||||
SizedBox(height: 24.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,7 +5,9 @@ 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';
|
||||
import 'package:tm_app/views/detail/widgets/tender_detail_card.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';
|
||||
|
||||
@@ -112,8 +114,10 @@ class _TenderDetailMobilePageState extends State<TenderDetailMobilePage> {
|
||||
children: [
|
||||
TenderDetailHeader(isScreenBig: false, detail: detail),
|
||||
SizedBox(height: 24.0.h()),
|
||||
TenderDetailCard(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,7 +4,9 @@ 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';
|
||||
import 'package:tm_app/views/detail/widgets/tender_detail_card.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';
|
||||
|
||||
@@ -123,8 +125,10 @@ class _TenderDetailTabletPageState extends State<TenderDetailTabletPage> {
|
||||
SizedBox(height: 28.0.h()),
|
||||
TenderDetailHeader(isScreenBig: true, detail: detail),
|
||||
SizedBox(height: 28.0.h()),
|
||||
TenderDetailCard(detail: detail),
|
||||
SizedBox(height: 24.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,4 +42,33 @@ 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).
|
||||
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).
|
||||
// Tender detail field labels
|
||||
static const String noticeTypeCode = 'Notice Type';
|
||||
static const String formType = 'Form Type';
|
||||
static const String noticeLanguageCode = 'Notice Language';
|
||||
static const String issueDate = 'Issue Date';
|
||||
static const String issueTime = 'Issue Time';
|
||||
static const String procedureCode = 'Procedure';
|
||||
static const String durationLabel = 'Duration';
|
||||
static const String publicationDate = 'Publication Date';
|
||||
static const String tenderDeadlineDate = 'Tender Deadline';
|
||||
static const String submissionDeadline = 'Submission Deadline';
|
||||
static const String applicationDeadline = 'Application Deadline';
|
||||
static const String countryCode = 'Country';
|
||||
static const String buyerName = 'Buyer';
|
||||
static const String companyId = 'Company ID';
|
||||
static const String buyerCity = 'City';
|
||||
static const String buyerCountry = 'Buyer Country';
|
||||
static const String officialLanguages = 'Official Languages';
|
||||
static const String status = 'Status';
|
||||
static const String tenderId = 'Tender ID';
|
||||
static const String lots = 'Lots';
|
||||
static const String lotId = 'Lot ID';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,25 +87,30 @@ class DesktopNavigationWidget extends StatelessWidget {
|
||||
iconPath: AssetsManager.tenders,
|
||||
activeIconPath: AssetsManager.tendersActive,
|
||||
),
|
||||
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,
|
||||
),
|
||||
// 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,
|
||||
// ),
|
||||
const SizedBox(width: 24),
|
||||
_navigationItem(
|
||||
context: context,
|
||||
|
||||
@@ -13,7 +13,6 @@ import 'package:tm_app/views/tenders/strings/tenders_strings.dart';
|
||||
import 'package:tm_app/views/tenders/widgets/main_tenders_slider.dart';
|
||||
import 'package:tm_app/views/tenders/widgets/tenders_filter_dialog.dart';
|
||||
import 'package:tm_app/views/tenders/widgets/tenders_sort_dialog.dart';
|
||||
import 'package:tm_app/views/tenders/widgets/windowed_pagination.dart';
|
||||
|
||||
import '../../../core/constants/common_strings.dart';
|
||||
import '../../../core/utils/app_toast.dart';
|
||||
@@ -78,32 +77,17 @@ class _DesktopTendersPageState extends State<DesktopTendersPage> {
|
||||
return const Center(child: Text(CommonStrings.noData));
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: 740,
|
||||
height: 800,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4.0.h()),
|
||||
child: MainTendersSlider(
|
||||
tenders: tenders,
|
||||
isDesktop: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 740,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4.0.h()),
|
||||
child: MainTendersSlider(
|
||||
tenders: tenders,
|
||||
isDesktop: true,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 740,
|
||||
child: WindowedPagination(
|
||||
currentPage: vm.currentPageIndex,
|
||||
totalPages: vm.totalPages,
|
||||
onPageChanged: vm.handlePaginationChange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -9,7 +9,6 @@ import '../../../core/utils/app_toast.dart';
|
||||
import '../../shared/tender_app_bar.dart';
|
||||
import '../strings/tenders_strings.dart';
|
||||
import '../widgets/main_tenders_slider.dart';
|
||||
import '../widgets/windowed_pagination.dart';
|
||||
|
||||
class MobileTendersPage extends StatefulWidget {
|
||||
const MobileTendersPage({super.key});
|
||||
@@ -62,29 +61,13 @@ class _MobileTendersPageState extends State<MobileTendersPage> {
|
||||
return const Center(child: Text(CommonStrings.noData));
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 18.0.h()),
|
||||
child: MainTendersSlider(
|
||||
key: ValueKey(
|
||||
'tenders-slider-${viewModel.currentPageIndex}',
|
||||
),
|
||||
tenders:
|
||||
viewModel.tendersResponse?.data?.tenders ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 18.0.h()),
|
||||
child: MainTendersSlider(
|
||||
tenders: viewModel.tendersResponse?.data?.tenders ?? [],
|
||||
),
|
||||
WindowedPagination(
|
||||
currentPage: viewModel.currentPageIndex,
|
||||
totalPages: viewModel.totalPages,
|
||||
onPageChanged: viewModel.handlePaginationChange,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:tm_app/core/utils/size_config.dart';
|
||||
import 'package:tm_app/view_models/tenders_view_model.dart';
|
||||
import 'package:tm_app/views/shared/tablet_navigation_widget.dart';
|
||||
import 'package:tm_app/views/tenders/widgets/main_tenders_slider.dart';
|
||||
import 'package:tm_app/views/tenders/widgets/windowed_pagination.dart';
|
||||
|
||||
import '../../../core/constants/common_strings.dart';
|
||||
import '../../shared/tender_app_bar.dart';
|
||||
@@ -54,30 +53,16 @@ class _TabletTendersPageState extends State<TabletTendersPage> {
|
||||
viewModel.isLoading == false) {
|
||||
return const Center(child: Text(CommonStrings.noData));
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: 24.0.w(),
|
||||
top: 128.0.h(),
|
||||
),
|
||||
child: MainTendersSlider(
|
||||
key: ValueKey(
|
||||
'tenders-slider-${viewModel.currentPageIndex}',
|
||||
),
|
||||
tenders: viewModel.tendersResponse?.data?.tenders ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: 24.0.w(),
|
||||
top: 128.0.h(),
|
||||
),
|
||||
WindowedPagination(
|
||||
currentPage: viewModel.currentPageIndex,
|
||||
totalPages: viewModel.totalPages,
|
||||
onPageChanged: viewModel.handlePaginationChange,
|
||||
child: MainTendersSlider(
|
||||
tenders: viewModel.tendersResponse?.data?.tenders ?? [],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -25,8 +25,41 @@ class MainTendersSlider extends StatefulWidget {
|
||||
|
||||
class _MainTendersSliderState extends State<MainTendersSlider> {
|
||||
final PageController pageController = PageController(initialPage: 0);
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int currentPage = 1;
|
||||
|
||||
/// Start prefetching the next page once this fraction of the currently
|
||||
/// loaded items has been scrolled/swiped past.
|
||||
static const double _prefetchFraction = 0.5;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
final position = _scrollController.position;
|
||||
if (position.maxScrollExtent <= 0) {
|
||||
return;
|
||||
}
|
||||
if (position.pixels >= position.maxScrollExtent * _prefetchFraction) {
|
||||
_maybeLoadMore();
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeLoadMore() {
|
||||
final viewModel = context.read<TendersViewModel>();
|
||||
if (viewModel.hasMoreData &&
|
||||
!viewModel.isLoadingMore &&
|
||||
!viewModel.isLoading) {
|
||||
viewModel.loadMoreTenders();
|
||||
}
|
||||
}
|
||||
|
||||
void _goToPreviousPage() {
|
||||
if (currentPage == 1) {
|
||||
return;
|
||||
@@ -82,6 +115,7 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
|
||||
@override
|
||||
void dispose() {
|
||||
pageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -115,6 +149,12 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
|
||||
if (!viewModel.hasFeedbackForTender(id)) {
|
||||
viewModel.getTenderFeedback(id);
|
||||
}
|
||||
|
||||
// Infinite scroll: once we pass the halfway point of the loaded
|
||||
// tenders, prefetch the next page in the background.
|
||||
if (index >= (widget.tenders.length * _prefetchFraction).floor()) {
|
||||
_maybeLoadMore();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final tender = widget.tenders[index];
|
||||
@@ -230,6 +270,7 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.all(16.0.h()),
|
||||
itemCount:
|
||||
widget.tenders.length +
|
||||
|
||||
@@ -8,7 +8,11 @@ import '../../../core/constants/assets.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../core/utils/date_utils.dart';
|
||||
import '../../../core/utils/size_config.dart';
|
||||
import '../../detail/strings/tender_details_strings.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';
|
||||
@@ -83,13 +87,17 @@ class TenderCard extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Match percentage
|
||||
_matchPercentage(),
|
||||
SizedBox(height: isDesktop ? 4.0.h() : 8.0.h()),
|
||||
|
||||
// Progress bar
|
||||
_progressBar(),
|
||||
SizedBox(height: isDesktop ? 8.0.h() : 16.0.h()),
|
||||
// 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(
|
||||
@@ -243,6 +251,11 @@ 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,
|
||||
@@ -287,6 +300,7 @@ class TenderCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
Widget _locationInfo() {
|
||||
return Expanded(
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tm_app/core/theme/colors.dart';
|
||||
|
||||
/// A windowed numbered pagination widget that mirrors the Next.js
|
||||
/// implementation: ±10 page-button window around the current page,
|
||||
/// 1-based labels, prev/next chevrons.
|
||||
class WindowedPagination extends StatelessWidget {
|
||||
const WindowedPagination({
|
||||
super.key,
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.onPageChanged,
|
||||
this.maxPageJump = 10,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
/// 0-based absolute current page index.
|
||||
final int currentPage;
|
||||
|
||||
/// Total number of pages (1-based count).
|
||||
final int totalPages;
|
||||
|
||||
/// Called with the 0-based target page index.
|
||||
final ValueChanged<int> onPageChanged;
|
||||
|
||||
/// Half-width of the page-button window (web uses 10).
|
||||
final int maxPageJump;
|
||||
|
||||
/// If true, renders smaller buttons (for mobile).
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (totalPages <= 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final clampedCurrent = currentPage.clamp(0, totalPages - 1);
|
||||
final windowStart = (clampedCurrent - maxPageJump).clamp(0, totalPages - 1);
|
||||
final windowEnd = (clampedCurrent + maxPageJump).clamp(0, totalPages - 1);
|
||||
|
||||
final buttons = <Widget>[];
|
||||
|
||||
buttons.add(
|
||||
_NavButton(
|
||||
icon: Icons.chevron_left,
|
||||
enabled: clampedCurrent > 0,
|
||||
compact: compact,
|
||||
onTap: () => onPageChanged(clampedCurrent - 1),
|
||||
),
|
||||
);
|
||||
|
||||
if (windowStart > 0) {
|
||||
buttons.add(_buildPageButton(0, clampedCurrent));
|
||||
if (windowStart > 1) {
|
||||
buttons.add(const _Ellipsis());
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = windowStart; i <= windowEnd; i++) {
|
||||
buttons.add(_buildPageButton(i, clampedCurrent));
|
||||
}
|
||||
|
||||
if (windowEnd < totalPages - 1) {
|
||||
if (windowEnd < totalPages - 2) {
|
||||
buttons.add(const _Ellipsis());
|
||||
}
|
||||
buttons.add(_buildPageButton(totalPages - 1, clampedCurrent));
|
||||
}
|
||||
|
||||
buttons.add(
|
||||
_NavButton(
|
||||
icon: Icons.chevron_right,
|
||||
enabled: clampedCurrent < totalPages - 1,
|
||||
compact: compact,
|
||||
onTap: () => onPageChanged(clampedCurrent + 1),
|
||||
),
|
||||
);
|
||||
|
||||
final gap = compact ? 4.0 : 6.0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: compact ? 8 : 12),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _withSpacing(buttons, gap),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageButton(int pageIndex, int currentPageIndex) {
|
||||
final isActive = pageIndex == currentPageIndex;
|
||||
return _PageButton(
|
||||
label: '${pageIndex + 1}',
|
||||
isActive: isActive,
|
||||
compact: compact,
|
||||
onTap: isActive ? null : () => onPageChanged(pageIndex),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Widget> _withSpacing(List<Widget> items, double gap) {
|
||||
final result = <Widget>[];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
result.add(items[i]);
|
||||
if (i != items.length - 1) {
|
||||
result.add(SizedBox(width: gap));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class _PageButton extends StatelessWidget {
|
||||
const _PageButton({
|
||||
required this.label,
|
||||
required this.isActive,
|
||||
required this.compact,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isActive;
|
||||
final bool compact;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = compact ? 32.0 : 38.0;
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: isActive
|
||||
? const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.mainBlue, AppColors.primaryColor],
|
||||
)
|
||||
: null,
|
||||
color: isActive ? null : AppColors.grey0,
|
||||
border: Border.all(
|
||||
color: isActive ? Colors.transparent : AppColors.grey30,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.mainBlue.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 12 : 13,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isActive ? Colors.white : AppColors.grey70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
required this.icon,
|
||||
required this.enabled,
|
||||
required this.compact,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final bool enabled;
|
||||
final bool compact;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = compact ? 32.0 : 38.0;
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: AppColors.grey0,
|
||||
border: Border.all(
|
||||
color: enabled ? AppColors.grey30 : AppColors.grey20,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: compact ? 18 : 20,
|
||||
color: enabled ? AppColors.grey70 : AppColors.grey40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Ellipsis extends StatelessWidget {
|
||||
const _Ellipsis();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(
|
||||
'…',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey60,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user