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
@@ -13,7 +13,8 @@ class TendersRepository {
Future<Result<TendersResponse>> getTenders({ Future<Result<TendersResponse>> getTenders({
required int limit, required int limit,
required int offset, int? offset,
String? cursor,
String? query, String? query,
String? sortBy, String? sortBy,
String? sortOrder, String? sortOrder,
@@ -23,6 +24,7 @@ class TendersRepository {
return _tendersService.getTenders( return _tendersService.getTenders(
limit: limit, limit: limit,
offset: offset, offset: offset,
cursor: cursor,
query: query, query: query,
sortBy: sortBy, sortBy: sortBy,
sortOrder: sortOrder, sortOrder: sortOrder,
+8 -2
View File
@@ -4,14 +4,20 @@ class TendersApi {
static const String tenders = '/api/v1/tenders'; static const String tenders = '/api/v1/tenders';
static String getTenders({ static String getTenders({
required int limit, required int limit,
required int offset, int? offset,
String? cursor,
String? query, String? query,
String? sortBy, String? sortBy,
String? sortOrder, String? sortOrder,
String? publicationDateFrom, String? publicationDateFrom,
String? publicationDateTo, String? publicationDateTo,
}) { }) {
String url = '$tenders?limit=$limit&offset=$offset'; String url = '$tenders?limit=$limit';
if (cursor != null && cursor.isNotEmpty) {
url += '&cursor=${Uri.encodeComponent(cursor)}';
} else if (offset != null) {
url += '&offset=$offset';
}
if (query != null && query.isNotEmpty) { if (query != null && query.isNotEmpty) {
url += '&q=${Uri.encodeComponent(query)}'; url += '&q=${Uri.encodeComponent(query)}';
} }
+3
View File
@@ -13,6 +13,9 @@ abstract class Meta with _$Meta {
required int? page, required int? page,
required int? pages, required int? pages,
required int? total, required int? total,
@JsonKey(name: 'has_more') bool? hasMore,
@JsonKey(name: 'next_cursor') String? nextCursor,
@JsonKey(name: 'prev_cursor') String? prevCursor,
}) = _Meta; }) = _Meta;
factory Meta.fromJson(Map<String, dynamic> json) => _$MetaFromJson(json); factory Meta.fromJson(Map<String, dynamic> json) => _$MetaFromJson(json);
+29 -20
View File
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$Meta { mixin _$Meta {
int? get limit; int? get offset; int? get page; int? get pages; int? get total; int? get limit; int? get offset; int? get page; int? get pages; int? get total;@JsonKey(name: 'has_more') bool? get hasMore;@JsonKey(name: 'next_cursor') String? get nextCursor;@JsonKey(name: 'prev_cursor') String? get prevCursor;
/// Create a copy of Meta /// Create a copy of Meta
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $MetaCopyWith<Meta> get copyWith => _$MetaCopyWithImpl<Meta>(this as Meta, _$ide
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Meta&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.offset, offset) || other.offset == offset)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages)&&(identical(other.total, total) || other.total == total)); return identical(this, other) || (other.runtimeType == runtimeType&&other is Meta&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.offset, offset) || other.offset == offset)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages)&&(identical(other.total, total) || other.total == total)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.nextCursor, nextCursor) || other.nextCursor == nextCursor)&&(identical(other.prevCursor, prevCursor) || other.prevCursor == prevCursor));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,limit,offset,page,pages,total); int get hashCode => Object.hash(runtimeType,limit,offset,page,pages,total,hasMore,nextCursor,prevCursor);
@override @override
String toString() { String toString() {
return 'Meta(limit: $limit, offset: $offset, page: $page, pages: $pages, total: $total)'; return 'Meta(limit: $limit, offset: $offset, page: $page, pages: $pages, total: $total, hasMore: $hasMore, nextCursor: $nextCursor, prevCursor: $prevCursor)';
} }
@@ -48,7 +48,7 @@ abstract mixin class $MetaCopyWith<$Res> {
factory $MetaCopyWith(Meta value, $Res Function(Meta) _then) = _$MetaCopyWithImpl; factory $MetaCopyWith(Meta value, $Res Function(Meta) _then) = _$MetaCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
int? limit, int? offset, int? page, int? pages, int? total int? limit, int? offset, int? page, int? pages, int? total,@JsonKey(name: 'has_more') bool? hasMore,@JsonKey(name: 'next_cursor') String? nextCursor,@JsonKey(name: 'prev_cursor') String? prevCursor
}); });
@@ -65,14 +65,17 @@ class _$MetaCopyWithImpl<$Res>
/// Create a copy of Meta /// Create a copy of Meta
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? limit = freezed,Object? offset = freezed,Object? page = freezed,Object? pages = freezed,Object? total = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? limit = freezed,Object? offset = freezed,Object? page = freezed,Object? pages = freezed,Object? total = freezed,Object? hasMore = freezed,Object? nextCursor = freezed,Object? prevCursor = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable
as int?,offset: freezed == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable as int?,offset: freezed == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable
as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable
as int?,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable as int?,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as int?, as int?,hasMore: freezed == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool?,nextCursor: freezed == nextCursor ? _self.nextCursor : nextCursor // ignore: cast_nullable_to_non_nullable
as String?,prevCursor: freezed == prevCursor ? _self.prevCursor : prevCursor // ignore: cast_nullable_to_non_nullable
as String?,
)); ));
} }
@@ -157,10 +160,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? limit, int? offset, int? page, int? pages, int? total)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? limit, int? offset, int? page, int? pages, int? total, @JsonKey(name: 'has_more') bool? hasMore, @JsonKey(name: 'next_cursor') String? nextCursor, @JsonKey(name: 'prev_cursor') String? prevCursor)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _Meta() when $default != null: case _Meta() when $default != null:
return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total);case _: return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total,_that.hasMore,_that.nextCursor,_that.prevCursor);case _:
return orElse(); return orElse();
} }
@@ -178,10 +181,10 @@ return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total);cas
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? limit, int? offset, int? page, int? pages, int? total) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? limit, int? offset, int? page, int? pages, int? total, @JsonKey(name: 'has_more') bool? hasMore, @JsonKey(name: 'next_cursor') String? nextCursor, @JsonKey(name: 'prev_cursor') String? prevCursor) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Meta(): case _Meta():
return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total);case _: return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total,_that.hasMore,_that.nextCursor,_that.prevCursor);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -198,10 +201,10 @@ return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total);cas
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? limit, int? offset, int? page, int? pages, int? total)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? limit, int? offset, int? page, int? pages, int? total, @JsonKey(name: 'has_more') bool? hasMore, @JsonKey(name: 'next_cursor') String? nextCursor, @JsonKey(name: 'prev_cursor') String? prevCursor)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Meta() when $default != null: case _Meta() when $default != null:
return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total);case _: return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total,_that.hasMore,_that.nextCursor,_that.prevCursor);case _:
return null; return null;
} }
@@ -213,7 +216,7 @@ return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total);cas
@JsonSerializable() @JsonSerializable()
class _Meta implements Meta { class _Meta implements Meta {
const _Meta({required this.limit, required this.offset, required this.page, required this.pages, required this.total}); const _Meta({required this.limit, required this.offset, required this.page, required this.pages, required this.total, @JsonKey(name: 'has_more') this.hasMore, @JsonKey(name: 'next_cursor') this.nextCursor, @JsonKey(name: 'prev_cursor') this.prevCursor});
factory _Meta.fromJson(Map<String, dynamic> json) => _$MetaFromJson(json); factory _Meta.fromJson(Map<String, dynamic> json) => _$MetaFromJson(json);
@override final int? limit; @override final int? limit;
@@ -221,6 +224,9 @@ class _Meta implements Meta {
@override final int? page; @override final int? page;
@override final int? pages; @override final int? pages;
@override final int? total; @override final int? total;
@override@JsonKey(name: 'has_more') final bool? hasMore;
@override@JsonKey(name: 'next_cursor') final String? nextCursor;
@override@JsonKey(name: 'prev_cursor') final String? prevCursor;
/// Create a copy of Meta /// Create a copy of Meta
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -235,16 +241,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Meta&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.offset, offset) || other.offset == offset)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages)&&(identical(other.total, total) || other.total == total)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _Meta&&(identical(other.limit, limit) || other.limit == limit)&&(identical(other.offset, offset) || other.offset == offset)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages)&&(identical(other.total, total) || other.total == total)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.nextCursor, nextCursor) || other.nextCursor == nextCursor)&&(identical(other.prevCursor, prevCursor) || other.prevCursor == prevCursor));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,limit,offset,page,pages,total); int get hashCode => Object.hash(runtimeType,limit,offset,page,pages,total,hasMore,nextCursor,prevCursor);
@override @override
String toString() { String toString() {
return 'Meta(limit: $limit, offset: $offset, page: $page, pages: $pages, total: $total)'; return 'Meta(limit: $limit, offset: $offset, page: $page, pages: $pages, total: $total, hasMore: $hasMore, nextCursor: $nextCursor, prevCursor: $prevCursor)';
} }
@@ -255,7 +261,7 @@ abstract mixin class _$MetaCopyWith<$Res> implements $MetaCopyWith<$Res> {
factory _$MetaCopyWith(_Meta value, $Res Function(_Meta) _then) = __$MetaCopyWithImpl; factory _$MetaCopyWith(_Meta value, $Res Function(_Meta) _then) = __$MetaCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
int? limit, int? offset, int? page, int? pages, int? total int? limit, int? offset, int? page, int? pages, int? total,@JsonKey(name: 'has_more') bool? hasMore,@JsonKey(name: 'next_cursor') String? nextCursor,@JsonKey(name: 'prev_cursor') String? prevCursor
}); });
@@ -272,14 +278,17 @@ class __$MetaCopyWithImpl<$Res>
/// Create a copy of Meta /// Create a copy of Meta
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? limit = freezed,Object? offset = freezed,Object? page = freezed,Object? pages = freezed,Object? total = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? limit = freezed,Object? offset = freezed,Object? page = freezed,Object? pages = freezed,Object? total = freezed,Object? hasMore = freezed,Object? nextCursor = freezed,Object? prevCursor = freezed,}) {
return _then(_Meta( return _then(_Meta(
limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable limit: freezed == limit ? _self.limit : limit // ignore: cast_nullable_to_non_nullable
as int?,offset: freezed == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable as int?,offset: freezed == offset ? _self.offset : offset // ignore: cast_nullable_to_non_nullable
as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable
as int?,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable as int?,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as int?, as int?,hasMore: freezed == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool?,nextCursor: freezed == nextCursor ? _self.nextCursor : nextCursor // ignore: cast_nullable_to_non_nullable
as String?,prevCursor: freezed == prevCursor ? _self.prevCursor : prevCursor // ignore: cast_nullable_to_non_nullable
as String?,
)); ));
} }
+6
View File
@@ -12,6 +12,9 @@ _Meta _$MetaFromJson(Map<String, dynamic> json) => _Meta(
page: (json['page'] as num?)?.toInt(), page: (json['page'] as num?)?.toInt(),
pages: (json['pages'] as num?)?.toInt(), pages: (json['pages'] as num?)?.toInt(),
total: (json['total'] as num?)?.toInt(), total: (json['total'] as num?)?.toInt(),
hasMore: json['has_more'] as bool?,
nextCursor: json['next_cursor'] as String?,
prevCursor: json['prev_cursor'] as String?,
); );
Map<String, dynamic> _$MetaToJson(_Meta instance) => <String, dynamic>{ Map<String, dynamic> _$MetaToJson(_Meta instance) => <String, dynamic>{
@@ -20,4 +23,7 @@ Map<String, dynamic> _$MetaToJson(_Meta instance) => <String, dynamic>{
'page': instance.page, 'page': instance.page,
'pages': instance.pages, 'pages': instance.pages,
'total': instance.total, 'total': instance.total,
'has_more': instance.hasMore,
'next_cursor': instance.nextCursor,
'prev_cursor': instance.prevCursor,
}; };
+3 -2
View File
@@ -16,7 +16,8 @@ class TendersService {
Future<Result<TendersResponse>> getTenders({ Future<Result<TendersResponse>> getTenders({
required int limit, required int limit,
required int offset, int? offset,
String? cursor,
String? query, String? query,
String? sortBy, String? sortBy,
String? sortOrder, String? sortOrder,
@@ -27,6 +28,7 @@ class TendersService {
TendersApi.getTenders( TendersApi.getTenders(
limit: limit, limit: limit,
offset: offset, offset: offset,
cursor: cursor,
query: query, query: query,
sortBy: sortBy, sortBy: sortBy,
sortOrder: sortOrder, sortOrder: sortOrder,
@@ -37,7 +39,6 @@ class TendersService {
method: 'GET', method: 'GET',
(json) => TendersResponse.fromJson(json), (json) => TendersResponse.fromJson(json),
); );
print(result);
return result; return result;
} }
+5 -4
View File
@@ -2,6 +2,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -130,6 +131,9 @@ class _MyAppState extends State<MyApp> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Listen to theme changes // Listen to theme changes
final themeProvider = context.watch<ThemeProvider>(); final themeProvider = context.watch<ThemeProvider>();
final pageTransition = kIsWeb
? const FadeUpwardsPageTransitionsBuilder()
: const CupertinoPageTransitionsBuilder();
return ToastificationWrapper( return ToastificationWrapper(
child: MaterialApp.router( child: MaterialApp.router(
@@ -147,10 +151,7 @@ class _MyAppState extends State<MyApp> {
pageTransitionsTheme: PageTransitionsTheme( pageTransitionsTheme: PageTransitionsTheme(
builders: { builders: {
for (final platform in TargetPlatform.values) for (final platform in TargetPlatform.values)
platform: platform: pageTransition,
kIsWeb
? const FadeUpwardsPageTransitionsBuilder()
: const CupertinoPageTransitionsBuilder(),
}, },
), ),
brightness: brightness:
+146 -123
View File
@@ -5,6 +5,7 @@ import 'package:tm_app/core/utils/logger.dart';
import 'package:tm_app/data/repositories/tenders_repository.dart'; import 'package:tm_app/data/repositories/tenders_repository.dart';
import 'package:tm_app/data/services/model/feedback_data/feedback_data.dart'; import 'package:tm_app/data/services/model/feedback_data/feedback_data.dart';
import 'package:tm_app/data/services/model/feedback_response/feedback_response.dart'; import 'package:tm_app/data/services/model/feedback_response/feedback_response.dart';
import 'package:tm_app/data/services/model/tender_data/tender_data.dart';
import 'package:tm_app/data/services/model/tenders/tenders_response/tenders_response.dart'; import 'package:tm_app/data/services/model/tenders/tenders_response/tenders_response.dart';
import 'package:tm_app/views/tenders/strings/tenders_strings.dart'; import 'package:tm_app/views/tenders/strings/tenders_strings.dart';
@@ -36,15 +37,44 @@ class TendersViewModel with ChangeNotifier {
List<FeedbackData> feedbacks = []; List<FeedbackData> feedbacks = [];
int currentPage = 1;
int _currentOffset = 0;
int _totalPages = 0;
int get totalPages => _totalPages;
static const int _pageSize = 10; static const int _pageSize = 10;
bool _hasMoreData = true;
// --- Offset pagination state ---
int _currentOffset = 0;
int _lastValidPages = 1;
int _lastValidLimit = _pageSize;
bool _hasMoreData = true;
bool get hasMoreData => _hasMoreData; bool get hasMoreData => _hasMoreData;
/// 0-based current page index.
int get currentPageIndex {
final meta = _tendersResponse?.meta;
final limit = meta?.limit ?? _lastValidLimit;
if (limit > 0) {
return _currentOffset ~/ limit;
}
if (meta?.offset != null && limit > 0) {
return meta!.offset! ~/ limit;
}
if (meta?.page != null) {
return meta!.page! - 1;
}
return 0;
}
/// 1-based current page (for display).
int get currentPage => currentPageIndex + 1;
int get totalPages {
final pages = _tendersResponse?.meta?.pages;
if (pages != null && pages > 0) {
return pages;
}
return _lastValidPages;
}
// Filter States // Filter States
DateTimeRange? selectedDateRange; DateTimeRange? selectedDateRange;
String selectedStatus = 'All'; String selectedStatus = 'All';
@@ -80,15 +110,12 @@ class TendersViewModel with ChangeNotifier {
} }
void setStatus(String status) { void setStatus(String status) {
// Clear data when status changes
_clearAllData(); _clearAllData();
selectedStatus = status; selectedStatus = status;
currentPage = 1;
notifyListeners(); notifyListeners();
} }
void setSort(String sort) { void setSort(String sort) {
// Clear data when status changes
_clearAllData(); _clearAllData();
selectedSort = sort; selectedSort = sort;
if (sort == TendersStrings.minimumTime) { if (sort == TendersStrings.minimumTime) {
@@ -98,16 +125,13 @@ class TendersViewModel with ChangeNotifier {
sortBy = 'submission_deadline'; sortBy = 'submission_deadline';
sortOrder = 'desc'; sortOrder = 'desc';
} }
currentPage = 1;
notifyListeners(); notifyListeners();
} }
void _clearAllData() { void _clearAllData() {
_tendersResponse = null; _tendersResponse = null;
_errorMessage = null; _errorMessage = null;
_currentOffset = 0; _resetPagination();
_hasMoreData = true;
currentPage = 1;
sortBy = ''; sortBy = '';
sortOrder = ''; sortOrder = '';
selectedSort = ''; selectedSort = '';
@@ -116,6 +140,11 @@ class TendersViewModel with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void _resetPagination() {
_currentOffset = 0;
_hasMoreData = true;
}
void init(BuildContext context) async { void init(BuildContext context) async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
@@ -151,10 +180,7 @@ class TendersViewModel with ChangeNotifier {
} }
Future<void> getTendersMobile({String? query}) async { Future<void> getTendersMobile({String? query}) async {
// Clear all data before fetching new tenders _resetPagination();
_currentOffset = 0;
_hasMoreData = true;
_searchQuery = query; _searchQuery = query;
final result = await _tendersRepository.getTenders( final result = await _tendersRepository.getTenders(
@@ -164,28 +190,7 @@ class TendersViewModel with ChangeNotifier {
sortBy: '', sortBy: '',
sortOrder: '', sortOrder: '',
); );
switch (result) { _handleListResult(result, fetchAllFeedbacks: false);
case Ok<TendersResponse>():
_tendersResponse = result.value;
// Check if we have more data
if (_tendersResponse?.data?.tenders != null) {
_hasMoreData = _tendersResponse!.data!.tenders!.length >= _pageSize;
}
if (_tendersResponse?.data?.tenders?.isNotEmpty == true) {
// Fetch feedback and approvals for the first tender only
getTenderFeedback(_tendersResponse!.data!.tenders!.first.id!);
}
if (_tendersResponse?.meta?.pages != null) {
_totalPages = _tendersResponse!.meta!.pages!;
} else {
_totalPages = 1;
}
notifyListeners();
break;
case Error<TendersResponse>():
_errorMessage = result.error.toString();
break;
}
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
_errorMessage = null; _errorMessage = null;
@@ -199,11 +204,9 @@ class TendersViewModel with ChangeNotifier {
String? publicationDateFrom, String? publicationDateFrom,
String? publicationDateTo, String? publicationDateTo,
}) async { }) async {
// Clear all data before fetching new tenders
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
_currentOffset = 0; _resetPagination();
_hasMoreData = true;
_searchQuery = query; _searchQuery = query;
final result = await _tendersRepository.getTenders( final result = await _tendersRepository.getTenders(
@@ -215,35 +218,55 @@ class TendersViewModel with ChangeNotifier {
publicationDateFrom: publicationDateFrom ?? '', publicationDateFrom: publicationDateFrom ?? '',
publicationDateTo: publicationDateTo ?? '', publicationDateTo: publicationDateTo ?? '',
); );
switch (result) { _handleListResult(result, fetchAllFeedbacks: true);
case Ok<TendersResponse>():
_tendersResponse = result.value;
// Check if we have more data
if (_tendersResponse?.data?.tenders != null) {
_hasMoreData = _tendersResponse!.data!.tenders!.length >= _pageSize;
}
if (_tendersResponse?.data?.tenders?.isNotEmpty == true) {
for (var tender in _tendersResponse!.data!.tenders!) {
getTenderFeedback(tender.id!);
}
}
if (_tendersResponse?.meta?.pages != null) {
_totalPages = _tendersResponse!.meta!.pages!;
} else {
_totalPages = 1;
}
notifyListeners();
break;
case Error<TendersResponse>():
_errorMessage = result.error.toString();
break;
}
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
} }
void _handleListResult(
Result<TendersResponse> result, {
required bool fetchAllFeedbacks,
}) {
switch (result) {
case Ok<TendersResponse>():
_tendersResponse = result.value;
final meta = _tendersResponse?.meta;
if (meta?.pages != null && meta!.pages! > 0) {
_lastValidPages = meta.pages!;
}
if (meta?.limit != null && meta!.limit! > 0) {
_lastValidLimit = meta.limit!;
}
if (meta?.offset != null) {
_currentOffset = meta!.offset!;
}
final tenders = _tendersResponse?.data?.tenders ?? [];
_hasMoreData =
meta?.hasMore ?? (tenders.length >= _pageSize);
if (tenders.isNotEmpty) {
if (fetchAllFeedbacks) {
for (final tender in tenders) {
if (tender.id != null) {
getTenderFeedback(tender.id!);
}
}
} else {
getTenderFeedback(tenders.first.id!);
}
}
break;
case Error<TendersResponse>():
_handleListError(result.error);
break;
}
}
void _handleListError(Exception error) {
_errorMessage = error.toString();
}
Future<void> loadMoreTenders() async { Future<void> loadMoreTenders() async {
if (_isLoadingMore || !_hasMoreData) { if (_isLoadingMore || !_hasMoreData) {
return; return;
@@ -253,7 +276,10 @@ class TendersViewModel with ChangeNotifier {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
_currentOffset += _pageSize; final meta = _tendersResponse?.meta;
final limit = meta?.limit ?? _pageSize;
_currentOffset += limit;
final result = await _tendersRepository.getTenders( final result = await _tendersRepository.getTenders(
limit: _pageSize, limit: _pageSize,
offset: _currentOffset, offset: _currentOffset,
@@ -264,30 +290,31 @@ class TendersViewModel with ChangeNotifier {
switch (result) { switch (result) {
case Ok<TendersResponse>(): case Ok<TendersResponse>():
if (result.value.data?.tenders != null && final newTenders = result.value.data?.tenders ?? <TenderData>[];
result.value.data!.tenders!.isNotEmpty) { if (newTenders.isNotEmpty) {
// Create a new modifiable list by combining existing and new tenders final existingTenders =
if (_tendersResponse?.data?.tenders != null) { _tendersResponse?.data?.tenders ?? <TenderData>[];
final existingTenders = _tendersResponse!.data!.tenders!; final combined = <TenderData>[...existingTenders, ...newTenders];
final newTenders = result.value.data!.tenders!; _tendersResponse = result.value.copyWith(
final combinedTenders = [...existingTenders, ...newTenders]; data: result.value.data?.copyWith(tenders: combined),
// Create a new TendersData with the combined list
_tendersResponse = _tendersResponse!.copyWith(
data: _tendersResponse!.data!.copyWith(tenders: combinedTenders),
); );
} else { final newMeta = result.value.meta;
_tendersResponse = result.value; if (newMeta?.pages != null && newMeta!.pages! > 0) {
_lastValidPages = newMeta.pages!;
} }
// Check if we have more data if (newMeta?.limit != null && newMeta!.limit! > 0) {
_hasMoreData = result.value.data!.tenders!.length >= _pageSize; _lastValidLimit = newMeta.limit!;
}
_hasMoreData =
newMeta?.hasMore ?? (newTenders.length >= _pageSize);
} else { } else {
_hasMoreData = false; _hasMoreData = false;
} }
break; break;
case Error<TendersResponse>(): case Error<TendersResponse>():
_errorMessage = result.error.toString(); final limit = meta?.limit ?? _pageSize;
_currentOffset -= _pageSize; // Revert offset on error _currentOffset = (_currentOffset - limit).clamp(0, 1 << 30);
_handleListError(result.error);
break; break;
} }
@@ -298,7 +325,6 @@ class TendersViewModel with ChangeNotifier {
} }
Future<void> getTenderFeedback(String tenderId) async { Future<void> getTenderFeedback(String tenderId) async {
// _isLoading = true;
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
@@ -308,7 +334,6 @@ class TendersViewModel with ChangeNotifier {
switch (result) { switch (result) {
case Ok<FeedbackResponse>(): case Ok<FeedbackResponse>():
_feedbackData = result.value.data; _feedbackData = result.value.data;
// Add or update _feedbackData in feedbacks list by tender id
if (_feedbackData != null) { if (_feedbackData != null) {
final index = feedbacks.indexWhere( final index = feedbacks.indexWhere(
(f) => f.tenderId == _feedbackData!.tenderId, (f) => f.tenderId == _feedbackData!.tenderId,
@@ -331,7 +356,6 @@ class TendersViewModel with ChangeNotifier {
_errorMessage = result.error.toString(); _errorMessage = result.error.toString();
break; break;
} }
// _isLoading = false;
notifyListeners(); notifyListeners();
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();
@@ -343,38 +367,32 @@ class TendersViewModel with ChangeNotifier {
}) async { }) async {
_feedbackErrorMessage = null; _feedbackErrorMessage = null;
// Get current feedback for this tender
final existingFeedback = getFeedbackForTender(tenderId); final existingFeedback = getFeedbackForTender(tenderId);
final existingFeedbackIndex = feedbacks.indexWhere( final existingFeedbackIndex = feedbacks.indexWhere(
(f) => f.tenderId == tenderId, (f) => f.tenderId == tenderId,
); );
// Create optimistic feedback data
FeedbackData? optimisticFeedback; FeedbackData? optimisticFeedback;
if (existingFeedback == null) { if (existingFeedback == null) {
// No existing feedback - create new optimistic feedback
optimisticFeedback = FeedbackData( optimisticFeedback = FeedbackData(
companyId: null, // Will be filled by server companyId: null,
createdAt: DateTime.now().millisecondsSinceEpoch, createdAt: DateTime.now().millisecondsSinceEpoch,
customerId: null, // Will be filled by server customerId: null,
feedbackType: feedbackType, feedbackType: feedbackType,
id: null, // Will be filled by server id: null,
tenderId: tenderId, tenderId: tenderId,
tender: null, tender: null,
updatedAt: DateTime.now().millisecondsSinceEpoch, updatedAt: DateTime.now().millisecondsSinceEpoch,
); );
feedbacks.add(optimisticFeedback); feedbacks.add(optimisticFeedback);
} else { } else {
// Existing feedback - update optimistically
if (existingFeedback.feedbackType == feedbackType) { if (existingFeedback.feedbackType == feedbackType) {
// Same feedback type - remove feedback (toggle off)
if (existingFeedbackIndex != -1) { if (existingFeedbackIndex != -1) {
feedbacks.removeAt(existingFeedbackIndex); feedbacks.removeAt(existingFeedbackIndex);
} }
optimisticFeedback = null; optimisticFeedback = null;
} else { } else {
// Different feedback type - update to new type
optimisticFeedback = existingFeedback.copyWith( optimisticFeedback = existingFeedback.copyWith(
feedbackType: feedbackType, feedbackType: feedbackType,
updatedAt: DateTime.now().millisecondsSinceEpoch, updatedAt: DateTime.now().millisecondsSinceEpoch,
@@ -385,10 +403,8 @@ class TendersViewModel with ChangeNotifier {
} }
} }
// Notify listeners immediately for optimistic update
notifyListeners(); notifyListeners();
// Call API
final result = await _tendersRepository.toggleFeedback( final result = await _tendersRepository.toggleFeedback(
tenderId: tenderId, tenderId: tenderId,
feedbackType: feedbackType, feedbackType: feedbackType,
@@ -398,7 +414,6 @@ class TendersViewModel with ChangeNotifier {
case Ok<FeedbackResponse>(): case Ok<FeedbackResponse>():
_feedbackData = result.value.data; _feedbackData = result.value.data;
if (_feedbackData != null) { if (_feedbackData != null) {
// Update with complete data from server
final index = feedbacks.indexWhere( final index = feedbacks.indexWhere(
(f) => f.tender == _feedbackData!.tender, (f) => f.tender == _feedbackData!.tender,
); );
@@ -408,17 +423,13 @@ class TendersViewModel with ChangeNotifier {
feedbacks.add(_feedbackData!); feedbacks.add(_feedbackData!);
} }
} else { } else {
// Server returned null data - remove feedback
feedbacks.removeWhere((f) => f.tenderId == tenderId); feedbacks.removeWhere((f) => f.tenderId == tenderId);
} }
break; break;
case Error<FeedbackResponse>(): case Error<FeedbackResponse>():
// Revert optimistic update on error
if (existingFeedback == null) { if (existingFeedback == null) {
// Remove the optimistic feedback we added
feedbacks.removeWhere((f) => f.tenderId == tenderId); feedbacks.removeWhere((f) => f.tenderId == tenderId);
} else { } else {
// Restore the original feedback - find current index and update, or add if not found
final currentIndex = feedbacks.indexWhere( final currentIndex = feedbacks.indexWhere(
(f) => f.tenderId == tenderId, (f) => f.tenderId == tenderId,
); );
@@ -437,7 +448,6 @@ class TendersViewModel with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Clear all feedbacks
void clearFeedbacks() { void clearFeedbacks() {
feedbacks.clear(); feedbacks.clear();
_feedbackData = null; _feedbackData = null;
@@ -445,13 +455,10 @@ class TendersViewModel with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Clear all tenders
void clearTenders() { void clearTenders() {
_tendersResponse = null; _tendersResponse = null;
_errorMessage = null; _errorMessage = null;
_currentOffset = 0; _resetPagination();
_hasMoreData = true;
currentPage = 1;
notifyListeners(); notifyListeners();
} }
@@ -464,7 +471,6 @@ class TendersViewModel with ChangeNotifier {
); );
} }
/// Search tenders with query
Future<void> searchTenders(String query) async { Future<void> searchTenders(String query) async {
await getTendersDesktop( await getTendersDesktop(
query: query.trim(), query: query.trim(),
@@ -475,7 +481,6 @@ class TendersViewModel with ChangeNotifier {
); );
} }
/// Clear all data (tenders, feedbacks, and approvals)
void clearAll() { void clearAll() {
clearTenders(); clearTenders();
clearFeedbacks(); clearFeedbacks();
@@ -488,17 +493,32 @@ class TendersViewModel with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void jumpToPage(int page) async { /// [selected] is the absolute 0-based target page index.
final meta = tendersResponse?.meta; Future<void> handlePaginationChange(int selected) async {
if (meta?.pages == null) { if (selected < 0) {
return; return;
} }
if (page < 1 || page > meta!.pages!) { final maxPage = totalPages - 1;
if (maxPage >= 0 && selected > maxPage) {
return; return;
} }
currentPage = page; if (selected == currentPageIndex) {
_currentOffset = (page - 1) * _pageSize; return;
}
await _goToOffsetPage(selected);
}
Future<void> _goToOffsetPage(int pageIndex) async {
final limit = _tendersResponse?.meta?.limit ?? _pageSize;
_currentOffset = pageIndex * limit;
await _fetchPage();
}
Future<void> _fetchPage() async {
_isLoading = true;
notifyListeners();
final result = await _tendersRepository.getTenders( final result = await _tendersRepository.getTenders(
limit: _pageSize, limit: _pageSize,
@@ -506,17 +526,20 @@ class TendersViewModel with ChangeNotifier {
query: _searchQuery, query: _searchQuery,
sortBy: sortBy, sortBy: sortBy,
sortOrder: sortOrder, sortOrder: sortOrder,
publicationDateFrom: startDateUnix,
publicationDateTo: endDateUnix,
); );
switch (result) { _handleListResult(result, fetchAllFeedbacks: true);
case Ok<TendersResponse>(): _isLoading = false;
_tendersResponse = result.value; notifyListeners();
break; _errorMessage = null;
case Error<TendersResponse>():
_errorMessage = result.error.toString();
break;
}
notifyListeners(); notifyListeners();
} }
/// Kept for backwards compatibility with existing callers.
/// Accepts a 1-based page number.
void jumpToPage(int page) {
handlePaginationChange(page - 1);
}
} }
+1 -1
View File
@@ -182,7 +182,7 @@ class DesktopHomePage extends StatelessWidget {
title: HomeStrings.likedTenders, title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(), amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50, textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0, enableTap: true,
width: 178, width: 178,
height: 148, height: 148,
onTap: () { onTap: () {
+1 -1
View File
@@ -157,7 +157,7 @@ class MobileHomePage extends StatelessWidget {
title: HomeStrings.likedTenders, title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(), amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50, textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0, enableTap: true,
onTap: () { onTap: () {
const LikedTendersRouteData().push(context).then((value) { const LikedTendersRouteData().push(context).then((value) {
homeViewModel.init(); homeViewModel.init();
+1 -1
View File
@@ -187,7 +187,7 @@ class TabletHomePage extends StatelessWidget {
title: HomeStrings.likedTenders, title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(), amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50, textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0, enableTap: true,
width: double.infinity, width: double.infinity,
height: 148, height: 148,
onTap: () { onTap: () {
@@ -87,16 +87,25 @@ class _DesktopNotificationPageState extends State<DesktopNotificationPage>
padding: EdgeInsetsDirectional.only(end: 24.0.w()), padding: EdgeInsetsDirectional.only(end: 24.0.w()),
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: InkWell( child: Builder(
onTap: () { builder: (context) {
viewModel.markAllAsRead(context); final hasNotifications = (viewModel
}, .allNotificationResponse
?.data
?.isNotEmpty ??
false);
return InkWell(
onTap: hasNotifications
? () => viewModel.markAllAsRead(context)
: null,
borderRadius: BorderRadius.circular(99), borderRadius: BorderRadius.circular(99),
child: Container( child: Container(
width: 132.0.w(), width: 132.0.w(),
height: 32.0.h(), height: 32.0.h(),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary20, color: hasNotifications
? AppColors.primary20
: AppColors.grey20,
borderRadius: BorderRadius.circular(99), borderRadius: BorderRadius.circular(99),
), ),
child: Center( child: Center(
@@ -105,11 +114,15 @@ class _DesktopNotificationPageState extends State<DesktopNotificationPage>
style: TextStyle( style: TextStyle(
fontSize: 14.0.sp(), fontSize: 14.0.sp(),
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.mainBlue, color: hasNotifications
? AppColors.mainBlue
: AppColors.grey50,
), ),
), ),
), ),
), ),
);
},
), ),
), ),
), ),
@@ -101,16 +101,23 @@ class _MobileNotificationPageState extends State<MobileNotificationPage>
padding: EdgeInsetsDirectional.only(end: 24.0.w()), padding: EdgeInsetsDirectional.only(end: 24.0.w()),
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: InkWell( child: Builder(
onTap: () { builder: (context) {
viewModel.markAllAsRead(context); final hasNotifications =
}, (viewModel.allNotificationResponse?.data?.isNotEmpty ??
false);
return InkWell(
onTap: hasNotifications
? () => viewModel.markAllAsRead(context)
: null,
borderRadius: BorderRadius.circular(99), borderRadius: BorderRadius.circular(99),
child: Container( child: Container(
width: 132.0.w(), width: 132.0.w(),
height: 32.0.h(), height: 32.0.h(),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary20, color: hasNotifications
? AppColors.primary20
: AppColors.grey20,
borderRadius: BorderRadius.circular(99), borderRadius: BorderRadius.circular(99),
), ),
child: Center( child: Center(
@@ -119,11 +126,15 @@ class _MobileNotificationPageState extends State<MobileNotificationPage>
style: TextStyle( style: TextStyle(
fontSize: 14.0.sp(), fontSize: 14.0.sp(),
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.mainBlue, color: hasNotifications
? AppColors.mainBlue
: AppColors.grey50,
), ),
), ),
), ),
), ),
);
},
), ),
), ),
), ),
@@ -90,16 +90,25 @@ class _TabletNotificationPageState extends State<TabletNotificationPage>
padding: EdgeInsetsDirectional.only(end: 24.0.w()), padding: EdgeInsetsDirectional.only(end: 24.0.w()),
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: InkWell( child: Builder(
onTap: () { builder: (context) {
viewModel.markAllAsRead(context); final hasNotifications = (viewModel
}, .allNotificationResponse
?.data
?.isNotEmpty ??
false);
return InkWell(
onTap: hasNotifications
? () => viewModel.markAllAsRead(context)
: null,
borderRadius: BorderRadius.circular(99), borderRadius: BorderRadius.circular(99),
child: Container( child: Container(
width: 132.0.w(), width: 132.0.w(),
height: 32.0.h(), height: 32.0.h(),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary20, color: hasNotifications
? AppColors.primary20
: AppColors.grey20,
borderRadius: BorderRadius.circular(99), borderRadius: BorderRadius.circular(99),
), ),
child: Center( child: Center(
@@ -108,11 +117,15 @@ class _TabletNotificationPageState extends State<TabletNotificationPage>
style: TextStyle( style: TextStyle(
fontSize: 14.0.sp(), fontSize: 14.0.sp(),
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.mainBlue, color: hasNotifications
? AppColors.mainBlue
: AppColors.grey50,
), ),
), ),
), ),
), ),
);
},
), ),
), ),
), ),
+7 -94
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/view_models/tenders_view_model.dart';
import 'package:tm_app/views/shared/base_button.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/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/strings/tenders_strings.dart';
import 'package:tm_app/views/tenders/widgets/main_tenders_slider.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_filter_dialog.dart';
import 'package:tm_app/views/tenders/widgets/tenders_sort_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/constants/common_strings.dart';
import '../../../core/utils/app_toast.dart'; import '../../../core/utils/app_toast.dart';
@@ -95,10 +95,13 @@ class _DesktopTendersPageState extends State<DesktopTendersPage> {
), ),
), ),
), ),
_DesktopPagination( SizedBox(
width: 740,
child: WindowedPagination(
currentPage: vm.currentPageIndex,
totalPages: vm.totalPages, totalPages: vm.totalPages,
currentPage: vm.currentPage, onPageChanged: vm.handlePaginationChange,
onPageSelected: vm.jumpToPage, ),
), ),
], ],
); );
@@ -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,
),
),
],
),
),
);
}
}
+19 -2
View File
@@ -9,6 +9,7 @@ import '../../../core/utils/app_toast.dart';
import '../../shared/tender_app_bar.dart'; import '../../shared/tender_app_bar.dart';
import '../strings/tenders_strings.dart'; import '../strings/tenders_strings.dart';
import '../widgets/main_tenders_slider.dart'; import '../widgets/main_tenders_slider.dart';
import '../widgets/windowed_pagination.dart';
class MobileTendersPage extends StatefulWidget { class MobileTendersPage extends StatefulWidget {
const MobileTendersPage({super.key}); const MobileTendersPage({super.key});
@@ -61,13 +62,29 @@ class _MobileTendersPageState extends State<MobileTendersPage> {
return const Center(child: Text(CommonStrings.noData)); return const Center(child: Text(CommonStrings.noData));
} }
return SingleChildScrollView( return Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(vertical: 18.0.h()), padding: EdgeInsets.symmetric(vertical: 18.0.h()),
child: MainTendersSlider( child: MainTendersSlider(
tenders: viewModel.tendersResponse?.data?.tenders ?? [], key: ValueKey(
'tenders-slider-${viewModel.currentPageIndex}',
),
tenders:
viewModel.tendersResponse?.data?.tenders ?? [],
), ),
), ),
),
),
WindowedPagination(
currentPage: viewModel.currentPageIndex,
totalPages: viewModel.totalPages,
onPageChanged: viewModel.handlePaginationChange,
compact: true,
),
],
); );
}, },
), ),
+16 -1
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/view_models/tenders_view_model.dart';
import 'package:tm_app/views/shared/tablet_navigation_widget.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/main_tenders_slider.dart';
import 'package:tm_app/views/tenders/widgets/windowed_pagination.dart';
import '../../../core/constants/common_strings.dart'; import '../../../core/constants/common_strings.dart';
import '../../shared/tender_app_bar.dart'; import '../../shared/tender_app_bar.dart';
@@ -53,16 +54,30 @@ class _TabletTendersPageState extends State<TabletTendersPage> {
viewModel.isLoading == false) { viewModel.isLoading == false) {
return const Center(child: Text(CommonStrings.noData)); return const Center(child: Text(CommonStrings.noData));
} }
return SingleChildScrollView( return Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Padding( child: Padding(
padding: EdgeInsetsDirectional.only( padding: EdgeInsetsDirectional.only(
end: 24.0.w(), end: 24.0.w(),
top: 128.0.h(), top: 128.0.h(),
), ),
child: MainTendersSlider( child: MainTendersSlider(
key: ValueKey(
'tenders-slider-${viewModel.currentPageIndex}',
),
tenders: viewModel.tendersResponse?.data?.tenders ?? [], 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); final PageController pageController = PageController(initialPage: 0);
int currentPage = 1; 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() { void _goToPreviousPage() {
if (currentPage == 1) { if (currentPage == 1) {
return; return;
@@ -107,7 +82,6 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
@override @override
void dispose() { void dispose() {
pageController.dispose(); pageController.dispose();
_scrollController?.dispose();
super.dispose(); super.dispose();
} }
@@ -141,12 +115,6 @@ class _MainTendersSliderState extends State<MainTendersSlider> {
if (!viewModel.hasFeedbackForTender(id)) { if (!viewModel.hasFeedbackForTender(id)) {
viewModel.getTenderFeedback(id); viewModel.getTenderFeedback(id);
} }
if (index >= widget.tenders.length - 1 &&
viewModel.hasMoreData &&
!viewModel.isLoadingMore) {
viewModel.loadMoreTenders();
}
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final tender = widget.tenders[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,
),
),
);
}
}
+319 -295
View File
File diff suppressed because it is too large Load Diff