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:
AmirReza Jamali
2026-05-26 18:30:46 +03:30
parent 02057988dc
commit ae08b946f6
20 changed files with 927 additions and 652 deletions
+1 -1
View File
@@ -182,7 +182,7 @@ class DesktopHomePage extends StatelessWidget {
title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0,
enableTap: true,
width: 178,
height: 148,
onTap: () {
+1 -1
View File
@@ -157,7 +157,7 @@ class MobileHomePage extends StatelessWidget {
title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0,
enableTap: true,
onTap: () {
const LikedTendersRouteData().push(context).then((value) {
homeViewModel.init();
+1 -1
View File
@@ -187,7 +187,7 @@ class TabletHomePage extends StatelessWidget {
title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0,
enableTap: true,
width: double.infinity,
height: 148,
onTap: () {
@@ -87,29 +87,42 @@ class _DesktopNotificationPageState extends State<DesktopNotificationPage>
padding: EdgeInsetsDirectional.only(end: 24.0.w()),
child: Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
viewModel.markAllAsRead(context);
},
borderRadius: BorderRadius.circular(99),
child: Container(
width: 132.0.w(),
height: 32.0.h(),
decoration: BoxDecoration(
color: AppColors.primary20,
child: Builder(
builder: (context) {
final hasNotifications = (viewModel
.allNotificationResponse
?.data
?.isNotEmpty ??
false);
return InkWell(
onTap: hasNotifications
? () => viewModel.markAllAsRead(context)
: null,
borderRadius: BorderRadius.circular(99),
),
child: Center(
child: Text(
NotificationStrings.markAllAsReadButton,
style: TextStyle(
fontSize: 14.0.sp(),
fontWeight: FontWeight.w500,
color: AppColors.mainBlue,
child: Container(
width: 132.0.w(),
height: 32.0.h(),
decoration: BoxDecoration(
color: hasNotifications
? AppColors.primary20
: AppColors.grey20,
borderRadius: BorderRadius.circular(99),
),
child: Center(
child: Text(
NotificationStrings.markAllAsReadButton,
style: TextStyle(
fontSize: 14.0.sp(),
fontWeight: FontWeight.w500,
color: hasNotifications
? AppColors.mainBlue
: AppColors.grey50,
),
),
),
),
),
),
);
},
),
),
),
@@ -101,29 +101,40 @@ class _MobileNotificationPageState extends State<MobileNotificationPage>
padding: EdgeInsetsDirectional.only(end: 24.0.w()),
child: Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
viewModel.markAllAsRead(context);
},
borderRadius: BorderRadius.circular(99),
child: Container(
width: 132.0.w(),
height: 32.0.h(),
decoration: BoxDecoration(
color: AppColors.primary20,
child: Builder(
builder: (context) {
final hasNotifications =
(viewModel.allNotificationResponse?.data?.isNotEmpty ??
false);
return InkWell(
onTap: hasNotifications
? () => viewModel.markAllAsRead(context)
: null,
borderRadius: BorderRadius.circular(99),
),
child: Center(
child: Text(
NotificationStrings.markAllAsReadButton,
style: TextStyle(
fontSize: 14.0.sp(),
fontWeight: FontWeight.w500,
color: AppColors.mainBlue,
child: Container(
width: 132.0.w(),
height: 32.0.h(),
decoration: BoxDecoration(
color: hasNotifications
? AppColors.primary20
: AppColors.grey20,
borderRadius: BorderRadius.circular(99),
),
child: Center(
child: Text(
NotificationStrings.markAllAsReadButton,
style: TextStyle(
fontSize: 14.0.sp(),
fontWeight: FontWeight.w500,
color: hasNotifications
? AppColors.mainBlue
: AppColors.grey50,
),
),
),
),
),
),
);
},
),
),
),
@@ -90,29 +90,42 @@ class _TabletNotificationPageState extends State<TabletNotificationPage>
padding: EdgeInsetsDirectional.only(end: 24.0.w()),
child: Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
viewModel.markAllAsRead(context);
},
borderRadius: BorderRadius.circular(99),
child: Container(
width: 132.0.w(),
height: 32.0.h(),
decoration: BoxDecoration(
color: AppColors.primary20,
child: Builder(
builder: (context) {
final hasNotifications = (viewModel
.allNotificationResponse
?.data
?.isNotEmpty ??
false);
return InkWell(
onTap: hasNotifications
? () => viewModel.markAllAsRead(context)
: null,
borderRadius: BorderRadius.circular(99),
),
child: Center(
child: Text(
NotificationStrings.markAllAsReadButton,
style: TextStyle(
fontSize: 14.0.sp(),
fontWeight: FontWeight.w500,
color: AppColors.mainBlue,
child: Container(
width: 132.0.w(),
height: 32.0.h(),
decoration: BoxDecoration(
color: hasNotifications
? AppColors.primary20
: AppColors.grey20,
borderRadius: BorderRadius.circular(99),
),
child: Center(
child: Text(
NotificationStrings.markAllAsReadButton,
style: TextStyle(
fontSize: 14.0.sp(),
fontWeight: FontWeight.w500,
color: hasNotifications
? AppColors.mainBlue
: AppColors.grey50,
),
),
),
),
),
),
);
},
),
),
),
+8 -95
View File
@@ -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,
),
),
],
),
),
);
}
}
+23 -6
View File
@@ -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,
),
],
);
},
),
+23 -8
View File
@@ -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,
),
),
);
}
}