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:
@@ -13,7 +13,8 @@ class TendersRepository {
|
||||
|
||||
Future<Result<TendersResponse>> getTenders({
|
||||
required int limit,
|
||||
required int offset,
|
||||
int? offset,
|
||||
String? cursor,
|
||||
String? query,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
@@ -23,6 +24,7 @@ class TendersRepository {
|
||||
return _tendersService.getTenders(
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
cursor: cursor,
|
||||
query: query,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
|
||||
@@ -4,14 +4,20 @@ class TendersApi {
|
||||
static const String tenders = '/api/v1/tenders';
|
||||
static String getTenders({
|
||||
required int limit,
|
||||
required int offset,
|
||||
int? offset,
|
||||
String? cursor,
|
||||
String? query,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
String? publicationDateFrom,
|
||||
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) {
|
||||
url += '&q=${Uri.encodeComponent(query)}';
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ abstract class Meta with _$Meta {
|
||||
required int? page,
|
||||
required int? pages,
|
||||
required int? total,
|
||||
@JsonKey(name: 'has_more') bool? hasMore,
|
||||
@JsonKey(name: 'next_cursor') String? nextCursor,
|
||||
@JsonKey(name: 'prev_cursor') String? prevCursor,
|
||||
}) = _Meta;
|
||||
|
||||
factory Meta.fromJson(Map<String, dynamic> json) => _$MetaFromJson(json);
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -28,16 +28,16 @@ $MetaCopyWith<Meta> get copyWith => _$MetaCopyWithImpl<Meta>(this as Meta, _$ide
|
||||
|
||||
@override
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@useResult
|
||||
$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
|
||||
/// 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(
|
||||
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?,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?,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) {
|
||||
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();
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
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');
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
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;
|
||||
|
||||
}
|
||||
@@ -213,7 +216,7 @@ return $default(_that.limit,_that.offset,_that.page,_that.pages,_that.total);cas
|
||||
@JsonSerializable()
|
||||
|
||||
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);
|
||||
|
||||
@override final int? limit;
|
||||
@@ -221,6 +224,9 @@ class _Meta implements Meta {
|
||||
@override final int? page;
|
||||
@override final int? pages;
|
||||
@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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -235,16 +241,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@override @useResult
|
||||
$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
|
||||
/// 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(
|
||||
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?,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?,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?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ _Meta _$MetaFromJson(Map<String, dynamic> json) => _Meta(
|
||||
page: (json['page'] as num?)?.toInt(),
|
||||
pages: (json['pages'] 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>{
|
||||
@@ -20,4 +23,7 @@ Map<String, dynamic> _$MetaToJson(_Meta instance) => <String, dynamic>{
|
||||
'page': instance.page,
|
||||
'pages': instance.pages,
|
||||
'total': instance.total,
|
||||
'has_more': instance.hasMore,
|
||||
'next_cursor': instance.nextCursor,
|
||||
'prev_cursor': instance.prevCursor,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,8 @@ class TendersService {
|
||||
|
||||
Future<Result<TendersResponse>> getTenders({
|
||||
required int limit,
|
||||
required int offset,
|
||||
int? offset,
|
||||
String? cursor,
|
||||
String? query,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
@@ -27,6 +28,7 @@ class TendersService {
|
||||
TendersApi.getTenders(
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
cursor: cursor,
|
||||
query: query,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
@@ -37,7 +39,6 @@ class TendersService {
|
||||
method: 'GET',
|
||||
(json) => TendersResponse.fromJson(json),
|
||||
);
|
||||
print(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -2,6 +2,7 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -130,6 +131,9 @@ class _MyAppState extends State<MyApp> {
|
||||
Widget build(BuildContext context) {
|
||||
// Listen to theme changes
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final pageTransition = kIsWeb
|
||||
? const FadeUpwardsPageTransitionsBuilder()
|
||||
: const CupertinoPageTransitionsBuilder();
|
||||
|
||||
return ToastificationWrapper(
|
||||
child: MaterialApp.router(
|
||||
@@ -147,10 +151,7 @@ class _MyAppState extends State<MyApp> {
|
||||
pageTransitionsTheme: PageTransitionsTheme(
|
||||
builders: {
|
||||
for (final platform in TargetPlatform.values)
|
||||
platform:
|
||||
kIsWeb
|
||||
? const FadeUpwardsPageTransitionsBuilder()
|
||||
: const CupertinoPageTransitionsBuilder(),
|
||||
platform: pageTransition,
|
||||
},
|
||||
),
|
||||
brightness:
|
||||
|
||||
@@ -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/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/tender_data/tender_data.dart';
|
||||
import 'package:tm_app/data/services/model/tenders/tenders_response/tenders_response.dart';
|
||||
import 'package:tm_app/views/tenders/strings/tenders_strings.dart';
|
||||
|
||||
@@ -36,15 +37,44 @@ class TendersViewModel with ChangeNotifier {
|
||||
|
||||
List<FeedbackData> feedbacks = [];
|
||||
|
||||
int currentPage = 1;
|
||||
int _currentOffset = 0;
|
||||
int _totalPages = 0;
|
||||
int get totalPages => _totalPages;
|
||||
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;
|
||||
|
||||
/// 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
|
||||
DateTimeRange? selectedDateRange;
|
||||
String selectedStatus = 'All';
|
||||
@@ -80,15 +110,12 @@ class TendersViewModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
void setStatus(String status) {
|
||||
// Clear data when status changes
|
||||
_clearAllData();
|
||||
selectedStatus = status;
|
||||
currentPage = 1;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSort(String sort) {
|
||||
// Clear data when status changes
|
||||
_clearAllData();
|
||||
selectedSort = sort;
|
||||
if (sort == TendersStrings.minimumTime) {
|
||||
@@ -98,16 +125,13 @@ class TendersViewModel with ChangeNotifier {
|
||||
sortBy = 'submission_deadline';
|
||||
sortOrder = 'desc';
|
||||
}
|
||||
currentPage = 1;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _clearAllData() {
|
||||
_tendersResponse = null;
|
||||
_errorMessage = null;
|
||||
_currentOffset = 0;
|
||||
_hasMoreData = true;
|
||||
currentPage = 1;
|
||||
_resetPagination();
|
||||
sortBy = '';
|
||||
sortOrder = '';
|
||||
selectedSort = '';
|
||||
@@ -116,6 +140,11 @@ class TendersViewModel with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _resetPagination() {
|
||||
_currentOffset = 0;
|
||||
_hasMoreData = true;
|
||||
}
|
||||
|
||||
void init(BuildContext context) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
@@ -151,10 +180,7 @@ class TendersViewModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> getTendersMobile({String? query}) async {
|
||||
// Clear all data before fetching new tenders
|
||||
|
||||
_currentOffset = 0;
|
||||
_hasMoreData = true;
|
||||
_resetPagination();
|
||||
_searchQuery = query;
|
||||
|
||||
final result = await _tendersRepository.getTenders(
|
||||
@@ -164,28 +190,7 @@ class TendersViewModel with ChangeNotifier {
|
||||
sortBy: '',
|
||||
sortOrder: '',
|
||||
);
|
||||
switch (result) {
|
||||
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;
|
||||
}
|
||||
_handleListResult(result, fetchAllFeedbacks: false);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
_errorMessage = null;
|
||||
@@ -199,11 +204,9 @@ class TendersViewModel with ChangeNotifier {
|
||||
String? publicationDateFrom,
|
||||
String? publicationDateTo,
|
||||
}) async {
|
||||
// Clear all data before fetching new tenders
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
_currentOffset = 0;
|
||||
_hasMoreData = true;
|
||||
_resetPagination();
|
||||
_searchQuery = query;
|
||||
|
||||
final result = await _tendersRepository.getTenders(
|
||||
@@ -215,35 +218,55 @@ class TendersViewModel with ChangeNotifier {
|
||||
publicationDateFrom: publicationDateFrom ?? '',
|
||||
publicationDateTo: publicationDateTo ?? '',
|
||||
);
|
||||
switch (result) {
|
||||
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;
|
||||
}
|
||||
_handleListResult(result, fetchAllFeedbacks: true);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
_errorMessage = null;
|
||||
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 {
|
||||
if (_isLoadingMore || !_hasMoreData) {
|
||||
return;
|
||||
@@ -253,7 +276,10 @@ class TendersViewModel with ChangeNotifier {
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
_currentOffset += _pageSize;
|
||||
final meta = _tendersResponse?.meta;
|
||||
final limit = meta?.limit ?? _pageSize;
|
||||
_currentOffset += limit;
|
||||
|
||||
final result = await _tendersRepository.getTenders(
|
||||
limit: _pageSize,
|
||||
offset: _currentOffset,
|
||||
@@ -264,30 +290,31 @@ class TendersViewModel with ChangeNotifier {
|
||||
|
||||
switch (result) {
|
||||
case Ok<TendersResponse>():
|
||||
if (result.value.data?.tenders != null &&
|
||||
result.value.data!.tenders!.isNotEmpty) {
|
||||
// Create a new modifiable list by combining existing and new tenders
|
||||
if (_tendersResponse?.data?.tenders != null) {
|
||||
final existingTenders = _tendersResponse!.data!.tenders!;
|
||||
final newTenders = result.value.data!.tenders!;
|
||||
final combinedTenders = [...existingTenders, ...newTenders];
|
||||
|
||||
// Create a new TendersData with the combined list
|
||||
_tendersResponse = _tendersResponse!.copyWith(
|
||||
data: _tendersResponse!.data!.copyWith(tenders: combinedTenders),
|
||||
final newTenders = result.value.data?.tenders ?? <TenderData>[];
|
||||
if (newTenders.isNotEmpty) {
|
||||
final existingTenders =
|
||||
_tendersResponse?.data?.tenders ?? <TenderData>[];
|
||||
final combined = <TenderData>[...existingTenders, ...newTenders];
|
||||
_tendersResponse = result.value.copyWith(
|
||||
data: result.value.data?.copyWith(tenders: combined),
|
||||
);
|
||||
} else {
|
||||
_tendersResponse = result.value;
|
||||
final newMeta = result.value.meta;
|
||||
if (newMeta?.pages != null && newMeta!.pages! > 0) {
|
||||
_lastValidPages = newMeta.pages!;
|
||||
}
|
||||
// Check if we have more data
|
||||
_hasMoreData = result.value.data!.tenders!.length >= _pageSize;
|
||||
if (newMeta?.limit != null && newMeta!.limit! > 0) {
|
||||
_lastValidLimit = newMeta.limit!;
|
||||
}
|
||||
_hasMoreData =
|
||||
newMeta?.hasMore ?? (newTenders.length >= _pageSize);
|
||||
} else {
|
||||
_hasMoreData = false;
|
||||
}
|
||||
break;
|
||||
case Error<TendersResponse>():
|
||||
_errorMessage = result.error.toString();
|
||||
_currentOffset -= _pageSize; // Revert offset on error
|
||||
final limit = meta?.limit ?? _pageSize;
|
||||
_currentOffset = (_currentOffset - limit).clamp(0, 1 << 30);
|
||||
_handleListError(result.error);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -298,7 +325,6 @@ class TendersViewModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> getTenderFeedback(String tenderId) async {
|
||||
// _isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
@@ -308,7 +334,6 @@ class TendersViewModel with ChangeNotifier {
|
||||
switch (result) {
|
||||
case Ok<FeedbackResponse>():
|
||||
_feedbackData = result.value.data;
|
||||
// Add or update _feedbackData in feedbacks list by tender id
|
||||
if (_feedbackData != null) {
|
||||
final index = feedbacks.indexWhere(
|
||||
(f) => f.tenderId == _feedbackData!.tenderId,
|
||||
@@ -331,7 +356,6 @@ class TendersViewModel with ChangeNotifier {
|
||||
_errorMessage = result.error.toString();
|
||||
break;
|
||||
}
|
||||
// _isLoading = false;
|
||||
notifyListeners();
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
@@ -343,38 +367,32 @@ class TendersViewModel with ChangeNotifier {
|
||||
}) async {
|
||||
_feedbackErrorMessage = null;
|
||||
|
||||
// Get current feedback for this tender
|
||||
final existingFeedback = getFeedbackForTender(tenderId);
|
||||
final existingFeedbackIndex = feedbacks.indexWhere(
|
||||
(f) => f.tenderId == tenderId,
|
||||
);
|
||||
|
||||
// Create optimistic feedback data
|
||||
FeedbackData? optimisticFeedback;
|
||||
|
||||
if (existingFeedback == null) {
|
||||
// No existing feedback - create new optimistic feedback
|
||||
optimisticFeedback = FeedbackData(
|
||||
companyId: null, // Will be filled by server
|
||||
companyId: null,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
customerId: null, // Will be filled by server
|
||||
customerId: null,
|
||||
feedbackType: feedbackType,
|
||||
id: null, // Will be filled by server
|
||||
id: null,
|
||||
tenderId: tenderId,
|
||||
tender: null,
|
||||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
feedbacks.add(optimisticFeedback);
|
||||
} else {
|
||||
// Existing feedback - update optimistically
|
||||
if (existingFeedback.feedbackType == feedbackType) {
|
||||
// Same feedback type - remove feedback (toggle off)
|
||||
if (existingFeedbackIndex != -1) {
|
||||
feedbacks.removeAt(existingFeedbackIndex);
|
||||
}
|
||||
optimisticFeedback = null;
|
||||
} else {
|
||||
// Different feedback type - update to new type
|
||||
optimisticFeedback = existingFeedback.copyWith(
|
||||
feedbackType: feedbackType,
|
||||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
@@ -385,10 +403,8 @@ class TendersViewModel with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners immediately for optimistic update
|
||||
notifyListeners();
|
||||
|
||||
// Call API
|
||||
final result = await _tendersRepository.toggleFeedback(
|
||||
tenderId: tenderId,
|
||||
feedbackType: feedbackType,
|
||||
@@ -398,7 +414,6 @@ class TendersViewModel with ChangeNotifier {
|
||||
case Ok<FeedbackResponse>():
|
||||
_feedbackData = result.value.data;
|
||||
if (_feedbackData != null) {
|
||||
// Update with complete data from server
|
||||
final index = feedbacks.indexWhere(
|
||||
(f) => f.tender == _feedbackData!.tender,
|
||||
);
|
||||
@@ -408,17 +423,13 @@ class TendersViewModel with ChangeNotifier {
|
||||
feedbacks.add(_feedbackData!);
|
||||
}
|
||||
} else {
|
||||
// Server returned null data - remove feedback
|
||||
feedbacks.removeWhere((f) => f.tenderId == tenderId);
|
||||
}
|
||||
break;
|
||||
case Error<FeedbackResponse>():
|
||||
// Revert optimistic update on error
|
||||
if (existingFeedback == null) {
|
||||
// Remove the optimistic feedback we added
|
||||
feedbacks.removeWhere((f) => f.tenderId == tenderId);
|
||||
} else {
|
||||
// Restore the original feedback - find current index and update, or add if not found
|
||||
final currentIndex = feedbacks.indexWhere(
|
||||
(f) => f.tenderId == tenderId,
|
||||
);
|
||||
@@ -437,7 +448,6 @@ class TendersViewModel with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all feedbacks
|
||||
void clearFeedbacks() {
|
||||
feedbacks.clear();
|
||||
_feedbackData = null;
|
||||
@@ -445,13 +455,10 @@ class TendersViewModel with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all tenders
|
||||
void clearTenders() {
|
||||
_tendersResponse = null;
|
||||
_errorMessage = null;
|
||||
_currentOffset = 0;
|
||||
_hasMoreData = true;
|
||||
currentPage = 1;
|
||||
_resetPagination();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -464,7 +471,6 @@ class TendersViewModel with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Search tenders with query
|
||||
Future<void> searchTenders(String query) async {
|
||||
await getTendersDesktop(
|
||||
query: query.trim(),
|
||||
@@ -475,7 +481,6 @@ class TendersViewModel with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear all data (tenders, feedbacks, and approvals)
|
||||
void clearAll() {
|
||||
clearTenders();
|
||||
clearFeedbacks();
|
||||
@@ -488,17 +493,32 @@ class TendersViewModel with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void jumpToPage(int page) async {
|
||||
final meta = tendersResponse?.meta;
|
||||
if (meta?.pages == null) {
|
||||
/// [selected] is the absolute 0-based target page index.
|
||||
Future<void> handlePaginationChange(int selected) async {
|
||||
if (selected < 0) {
|
||||
return;
|
||||
}
|
||||
if (page < 1 || page > meta!.pages!) {
|
||||
final maxPage = totalPages - 1;
|
||||
if (maxPage >= 0 && selected > maxPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentPage = page;
|
||||
_currentOffset = (page - 1) * _pageSize;
|
||||
if (selected == currentPageIndex) {
|
||||
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(
|
||||
limit: _pageSize,
|
||||
@@ -506,17 +526,20 @@ class TendersViewModel with ChangeNotifier {
|
||||
query: _searchQuery,
|
||||
sortBy: sortBy,
|
||||
sortOrder: sortOrder,
|
||||
publicationDateFrom: startDateUnix,
|
||||
publicationDateTo: endDateUnix,
|
||||
);
|
||||
|
||||
switch (result) {
|
||||
case Ok<TendersResponse>():
|
||||
_tendersResponse = result.value;
|
||||
break;
|
||||
case Error<TendersResponse>():
|
||||
_errorMessage = result.error.toString();
|
||||
break;
|
||||
}
|
||||
|
||||
_handleListResult(result, fetchAllFeedbacks: true);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Kept for backwards compatibility with existing callers.
|
||||
/// Accepts a 1-based page number.
|
||||
void jumpToPage(int page) {
|
||||
handlePaginationChange(page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,16 +87,25 @@ class _DesktopNotificationPageState extends State<DesktopNotificationPage>
|
||||
padding: EdgeInsetsDirectional.only(end: 24.0.w()),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
viewModel.markAllAsRead(context);
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final hasNotifications = (viewModel
|
||||
.allNotificationResponse
|
||||
?.data
|
||||
?.isNotEmpty ??
|
||||
false);
|
||||
return InkWell(
|
||||
onTap: hasNotifications
|
||||
? () => viewModel.markAllAsRead(context)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: Container(
|
||||
width: 132.0.w(),
|
||||
height: 32.0.h(),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary20,
|
||||
color: hasNotifications
|
||||
? AppColors.primary20
|
||||
: AppColors.grey20,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Center(
|
||||
@@ -105,11 +114,15 @@ class _DesktopNotificationPageState extends State<DesktopNotificationPage>
|
||||
style: TextStyle(
|
||||
fontSize: 14.0.sp(),
|
||||
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()),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
viewModel.markAllAsRead(context);
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final hasNotifications =
|
||||
(viewModel.allNotificationResponse?.data?.isNotEmpty ??
|
||||
false);
|
||||
return InkWell(
|
||||
onTap: hasNotifications
|
||||
? () => viewModel.markAllAsRead(context)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: Container(
|
||||
width: 132.0.w(),
|
||||
height: 32.0.h(),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary20,
|
||||
color: hasNotifications
|
||||
? AppColors.primary20
|
||||
: AppColors.grey20,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Center(
|
||||
@@ -119,11 +126,15 @@ class _MobileNotificationPageState extends State<MobileNotificationPage>
|
||||
style: TextStyle(
|
||||
fontSize: 14.0.sp(),
|
||||
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()),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
viewModel.markAllAsRead(context);
|
||||
},
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final hasNotifications = (viewModel
|
||||
.allNotificationResponse
|
||||
?.data
|
||||
?.isNotEmpty ??
|
||||
false);
|
||||
return InkWell(
|
||||
onTap: hasNotifications
|
||||
? () => viewModel.markAllAsRead(context)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: Container(
|
||||
width: 132.0.w(),
|
||||
height: 32.0.h(),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary20,
|
||||
color: hasNotifications
|
||||
? AppColors.primary20
|
||||
: AppColors.grey20,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Center(
|
||||
@@ -108,11 +117,15 @@ class _TabletNotificationPageState extends State<TabletNotificationPage>
|
||||
style: TextStyle(
|
||||
fontSize: 14.0.sp(),
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.mainBlue,
|
||||
color: hasNotifications
|
||||
? AppColors.mainBlue
|
||||
: AppColors.grey50,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
SizedBox(
|
||||
width: 740,
|
||||
child: WindowedPagination(
|
||||
currentPage: vm.currentPageIndex,
|
||||
totalPages: vm.totalPages,
|
||||
currentPage: vm.currentPage,
|
||||
onPageSelected: vm.jumpToPage,
|
||||
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(
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 18.0.h()),
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
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 ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+319
-295
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user