Refactor tenders pagination and enhance API integration
- Updated TendersRepository and TendersService to support cursor-based pagination. - Modified TendersViewModel to manage pagination state and handle API responses more effectively. - Replaced existing pagination UI components with a new WindowedPagination widget across desktop, mobile, and tablet views. - Improved notification handling in the UI to reflect the presence of notifications dynamically. - Adjusted main tenders slider to ensure proper rendering based on the current page index.
This commit is contained in:
@@ -9,11 +9,11 @@ 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/base_button.dart';
|
||||
import 'package:tm_app/views/shared/desktop_navigation_widget.dart';
|
||||
import 'package:tm_app/views/shared/page_selection_dialog.dart';
|
||||
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';
|
||||
@@ -95,10 +95,13 @@ class _DesktopTendersPageState extends State<DesktopTendersPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_DesktopPagination(
|
||||
totalPages: vm.totalPages,
|
||||
currentPage: vm.currentPage,
|
||||
onPageSelected: vm.jumpToPage,
|
||||
SizedBox(
|
||||
width: 740,
|
||||
child: WindowedPagination(
|
||||
currentPage: vm.currentPageIndex,
|
||||
totalPages: vm.totalPages,
|
||||
onPageChanged: vm.handlePaginationChange,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -264,93 +267,3 @@ class _ActionButtons extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DesktopPagination extends StatelessWidget {
|
||||
final int totalPages;
|
||||
final int currentPage;
|
||||
final ValueChanged<int> onPageSelected;
|
||||
|
||||
const _DesktopPagination({
|
||||
required this.totalPages,
|
||||
required this.currentPage,
|
||||
required this.onPageSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 680,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0.h()),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Text(
|
||||
TendersStrings.page,
|
||||
style: TextStyle(
|
||||
fontSize: 13.0.sp(),
|
||||
fontWeight: FontWeight.w300,
|
||||
color: AppColors.grey60,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.0.w()),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final selectedPage = await showDialog<int>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => PageSelectionDialog(
|
||||
totalPages: totalPages,
|
||||
currentPage: currentPage,
|
||||
),
|
||||
);
|
||||
if (selectedPage != null) {
|
||||
onPageSelected(selectedPage);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.grey30, width: 1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 5.0.w()),
|
||||
Text(
|
||||
'$currentPage',
|
||||
style: TextStyle(
|
||||
fontSize: 14.0.sp(),
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey70,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5.0.w()),
|
||||
Icon(Icons.arrow_drop_down, color: AppColors.grey80),
|
||||
SizedBox(width: 5.0.w()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5.0.w()),
|
||||
Text(
|
||||
TendersStrings.of,
|
||||
style: TextStyle(
|
||||
fontSize: 14.0.sp(),
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.grey60,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5.0.w()),
|
||||
Text(
|
||||
'$totalPages',
|
||||
style: TextStyle(
|
||||
fontSize: 13.0.sp(),
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.grey60,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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});
|
||||
@@ -61,13 +62,29 @@ class _MobileTendersPageState extends State<MobileTendersPage> {
|
||||
return const Center(child: Text(CommonStrings.noData));
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 18.0.h()),
|
||||
child: MainTendersSlider(
|
||||
tenders: viewModel.tendersResponse?.data?.tenders ?? [],
|
||||
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 ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
WindowedPagination(
|
||||
currentPage: viewModel.currentPageIndex,
|
||||
totalPages: viewModel.totalPages,
|
||||
onPageChanged: viewModel.handlePaginationChange,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -53,16 +54,30 @@ class _TabletTendersPageState extends State<TabletTendersPage> {
|
||||
viewModel.isLoading == false) {
|
||||
return const Center(child: Text(CommonStrings.noData));
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: 24.0.w(),
|
||||
top: 128.0.h(),
|
||||
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 ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: MainTendersSlider(
|
||||
tenders: viewModel.tendersResponse?.data?.tenders ?? [],
|
||||
WindowedPagination(
|
||||
currentPage: viewModel.currentPageIndex,
|
||||
totalPages: viewModel.totalPages,
|
||||
onPageChanged: viewModel.handlePaginationChange,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -27,31 +27,6 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
|
||||
final PageController pageController = PageController(initialPage: 0);
|
||||
int currentPage = 1;
|
||||
|
||||
ScrollController? _scrollController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.isDesktop) {
|
||||
_scrollController = ScrollController();
|
||||
_scrollController!.addListener(_onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!widget.isDesktop) {
|
||||
return;
|
||||
}
|
||||
final viewModel = context.read<TendersViewModel>();
|
||||
if (_scrollController!.position.pixels >=
|
||||
_scrollController!.position.maxScrollExtent - 200 &&
|
||||
viewModel.hasMoreData &&
|
||||
!viewModel.isLoadingMore) {
|
||||
viewModel.loadMoreTenders();
|
||||
}
|
||||
}
|
||||
|
||||
void _goToPreviousPage() {
|
||||
if (currentPage == 1) {
|
||||
return;
|
||||
@@ -107,7 +82,6 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
|
||||
@override
|
||||
void dispose() {
|
||||
pageController.dispose();
|
||||
_scrollController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -141,12 +115,6 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
|
||||
if (!viewModel.hasFeedbackForTender(id)) {
|
||||
viewModel.getTenderFeedback(id);
|
||||
}
|
||||
|
||||
if (index >= widget.tenders.length - 1 &&
|
||||
viewModel.hasMoreData &&
|
||||
!viewModel.isLoadingMore) {
|
||||
viewModel.loadMoreTenders();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final tender = widget.tenders[index];
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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