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({
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,
+8 -2
View File
@@ -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)}';
}
+3
View File
@@ -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);
+29 -20
View File
@@ -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?,
));
}
+6
View File
@@ -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,
};
+3 -2
View File
@@ -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
View File
@@ -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:
+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/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);
}
}
+1 -1
View File
@@ -182,7 +182,7 @@ class DesktopHomePage extends StatelessWidget {
title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0,
enableTap: true,
width: 178,
height: 148,
onTap: () {
+1 -1
View File
@@ -157,7 +157,7 @@ class MobileHomePage extends StatelessWidget {
title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0,
enableTap: true,
onTap: () {
const LikedTendersRouteData().push(context).then((value) {
homeViewModel.init();
+1 -1
View File
@@ -187,7 +187,7 @@ class TabletHomePage extends StatelessWidget {
title: HomeStrings.likedTenders,
amount: homeViewModel.userLikedTendersCount.toString(),
textColor: AppColors.grey50,
enableTap: homeViewModel.userLikedTendersCount != 0,
enableTap: true,
width: double.infinity,
height: 148,
onTap: () {
@@ -87,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,
),
),
),
),
);
},
),
),
),
+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/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,
),
),
],
),
),
);
}
}
+19 -2
View File
@@ -9,6 +9,7 @@ import '../../../core/utils/app_toast.dart';
import '../../shared/tender_app_bar.dart';
import '../strings/tenders_strings.dart';
import '../widgets/main_tenders_slider.dart';
import '../widgets/windowed_pagination.dart';
class MobileTendersPage extends StatefulWidget {
const MobileTendersPage({super.key});
@@ -61,13 +62,29 @@ class _MobileTendersPageState extends State<MobileTendersPage> {
return const Center(child: Text(CommonStrings.noData));
}
return SingleChildScrollView(
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,
),
],
);
},
),
+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/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
View File
File diff suppressed because it is too large Load Diff