Merge remote-tracking branch 'origin/wala_plus_with_dialog_removal' into wala_plus_with_dialog_removal

wala_plus_with_dialog_removal
FaizHashmi 2 weeks ago
commit c976b9c9db

@ -829,5 +829,6 @@
"merchantWebsiteNotAvailable": "موقع التاجر غير متوفر",
"locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة",
"locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة",
"favoriteMerchant": "العروض المفضلة"
"favoriteMerchant": "العروض المفضلة",
"favoriteOffers": "العروض المفضلة"
}

@ -833,6 +833,7 @@
"favoriteAddedSuccess": "Added to favorites successfully",
"favoriteRemovedSuccess": "Removed from favorites successfully",
"favoriteError": "An error occurred while updating favorites",
"favoriteMerchant": "Favorite Offers"
"favoriteMerchant": "Favorite Offers",
"favoriteOffers": "Favorite Offers"
}

@ -845,7 +845,8 @@ class CodegenLoader extends AssetLoader{
"merchantWebsiteNotAvailable": "موقع التاجر غير متوفر",
"locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة",
"locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة",
"favoriteMerchant": "العروض المفضلة"
"favoriteMerchant": "العروض المفضلة",
"favoriteOffers": "العروض المفضلة"
};
static const Map<String,dynamic> _en_US = {
"mohemm": "Mohemm",
@ -1678,7 +1679,8 @@ static const Map<String,dynamic> _en_US = {
"favoriteAddedSuccess": "Added to favorites successfully",
"favoriteRemovedSuccess": "Removed from favorites successfully",
"favoriteError": "An error occurred while updating favorites",
"favoriteMerchant": "Favorite Offers"
"favoriteMerchant": "Favorite Offers",
"favoriteOffers": "Favorite Offers"
};
static const Map<String, Map<String,dynamic>> mapLocales = {"ar_SA": _ar_SA, "en_US": _en_US};
}

@ -818,5 +818,6 @@ abstract class LocaleKeys {
static const locationServicesRequiredForNearestOffers = 'locationServicesRequiredForNearestOffers';
static const locationPermissionRequiredForNearestOffers = 'locationPermissionRequiredForNearestOffers';
static const favoriteMerchant = 'favoriteMerchant';
static const favoriteOffers = 'favoriteOffers';
}

@ -57,7 +57,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
String _filterDistance = 'any';
String _filterMinDiscount = 'any';
bool isNearestOffers = true;
bool isFavoriteOnly = false;
bool isLoading = true;
bool isLoadingMore = false;
bool isCategoriesLoading = true;
@ -72,26 +71,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
final ScrollController _scrollController = ScrollController();
bool _hasRegisteredMobileLoginInfo = false;
String _getTypeFilterTitle(String type) {
if (type == 'all') {
return LocaleKeys.allCategories.tr();
} else if (type == 'instore') {
return LocaleKeys.inStore.tr();
} else if (type == 'online') {
return LocaleKeys.online.tr();
}
return type.isNotEmpty ? (type[0].toUpperCase() + type.substring(1)) : type;
}
IconData _getTypeFilterIcon(String type) {
if (type == 'instore') {
return Icons.storefront_rounded;
} else if (type == 'online') {
return Icons.language_rounded;
}
return Icons.tune_rounded;
}
@override
void initState() {
super.initState();
@ -156,14 +135,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
return name.replaceAll(RegExp(r'\s*-\s*'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
}
String? _getUserReferenceForFavorites() {
String ref = OffersAndDiscountsApiClient().resolveUserReference();
if (ref.trim().isNotEmpty) return ref.trim();
String? userName = AppState().getUserName;
if (userName != null && userName.trim().isNotEmpty) return userName.trim();
return null;
}
@override
void dispose() {
_debounceTimer?.cancel();
@ -310,35 +281,28 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
});
try {
String catParam = (isFavoriteOnly || targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId;
String? offerTypeParam = isFavoriteOnly ? null : OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
String catParam = (targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId;
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
int? radiusParam = _radiusFromFilterDistance(_filterDistance);
int? minDiscountParam = _filterMinDiscount == 'any' ? null : int.tryParse(_filterMinDiscount);
String? userRef = isFavoriteOnly ? _getUserReferenceForFavorites() : null;
print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', query='$queryParam', userReference='$userRef', page=1, per_page=$pageSize");
print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', query='$queryParam', page=1, per_page=$pageSize");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: catParam,
page: 1,
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
sortBy: isFavoriteOnly ? null : _filterSortBy,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
// Same as getWalaPlusRetailerById: always pass context whenever we have one so
// WP_Geo_Location is attempted - don't gate it on wantsNearest, that only matters for
// whether we block the fetch to prompt for permission first (see the gate above).
context: isFavoriteOnly ? null : context,
userReference: userRef,
context: context,
);
retailersList = response?.retailers ?? <WalaPlusRetailerModel>[];
if (isFavoriteOnly) {
for (var r in retailersList) {
r.isFavorite = true;
}
}
_filteredRetailersList = retailersList;
currentPage = 1;
totalPages = response?.pagesCount ?? 1;
@ -371,14 +335,13 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
try {
int nextPage = currentPage + 1;
String catParam = (isFavoriteOnly || currentCategoryId == "all" || currentCategoryId == "0") ? "" : currentCategoryId;
String? offerTypeParam = isFavoriteOnly ? null : OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
String catParam = (currentCategoryId == "all" || currentCategoryId == "0") ? "" : currentCategoryId;
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
String? userRef = isFavoriteOnly ? _getUserReferenceForFavorites() : null;
int? radiusParam = isFavoriteOnly ? null : _radiusFromFilterDistance(_filterDistance);
int? minDiscountParam = (isFavoriteOnly || _filterMinDiscount == 'any') ? null : int.tryParse(_filterMinDiscount);
int? radiusParam = _radiusFromFilterDistance(_filterDistance);
int? minDiscountParam = (_filterMinDiscount == 'any') ? null : int.tryParse(_filterMinDiscount);
print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam', userReference='$userRef'");
print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam'");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: catParam,
@ -386,20 +349,14 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
sortBy: isFavoriteOnly ? null : _filterSortBy,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
context: isFavoriteOnly ? null : context,
userReference: userRef,
context: context,
);
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? [];
if (newRetailers.isNotEmpty) {
if (isFavoriteOnly) {
for (var r in newRetailers) {
r.isFavorite = true;
}
}
currentPage = nextPage;
totalPages = response?.pagesCount ?? totalPages;
hasMore = currentPage < totalPages;
@ -492,11 +449,7 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
void _openRetailer(WalaPlusRetailerModel item) async {
await Navigator.pushNamed(context, AppRoutes.walaPlusRetailerDetails, arguments: item);
if (mounted) {
if (isFavoriteOnly) {
fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId);
} else {
setState(() {});
}
setState(() {});
}
}
@ -675,15 +628,10 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
message: LocaleKeys.favorite.tr(),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
setState(() {
isFavoriteOnly = !isFavoriteOnly;
});
fetchOffers(categoryId: isFavoriteOnly ? "" : (currentCategoryId.isEmpty ? "all" : currentCategoryId));
},
child: Padding(
padding: const EdgeInsets.all(3),
child: Icon(isFavoriteOnly ? Icons.favorite_rounded : Icons.favorite_border_rounded, size: 24, color: isFavoriteOnly ? Colors.black : const Color(0xFF1E293B)),
onTap: _showFavoritesBottomSheet,
child: const Padding(
padding: EdgeInsets.all(3),
child: Icon(Icons.favorite_rounded, size: 24, color: Colors.black),
),
),
),
@ -723,23 +671,16 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
// Search Input Card
_buildSearchBar(isAr),
if (isFavoriteOnly) ...[
Padding(
padding: const EdgeInsets.only(top: 22, bottom: 14),
child: LocaleKeys.favoriteMerchant.tr().toText18(weight: FontWeight.w600, color: const Color(0xFF1E293B)),
),
] else ...[
// "Browse Categories" Header
_buildBrowseCategoriesHeader(isAr),
// "Browse Categories" Header
_buildBrowseCategoriesHeader(isAr),
// Categories Section
if (categoriesList.isNotEmpty) _buildCategoriesList(isAr) else _buildCategoriesShimmer(),
// Categories Section
if (categoriesList.isNotEmpty) _buildCategoriesList(isAr) else _buildCategoriesShimmer(),
// Type Filter Chips (All, In Store, Online)
_buildFiltersRow(isAr),
// Type Filter Chips (All, In Store, Online)
_buildFiltersRow(isAr),
const SizedBox(height: 16),
],
const SizedBox(height: 16),
// Offers Grid
if (isLoading)
@ -1206,6 +1147,21 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
);
}
void _showFavoritesBottomSheet() async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
elevation: 0,
builder: (BuildContext sheetContext) {
return const FavoriteMerchantsBottomSheet();
},
);
if (mounted) {
setState(() {});
}
}
Widget _buildCategoryCard(WalaPlusCategoryModel item, bool isSelected) {
const double cardSize = 84.0;
const double borderRadiusVal = 18.0;
@ -1304,7 +1260,99 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
return Icon(iconData, color: color, size: 26);
}
String _getDiscountBadge(WalaPlusRetailerModel item) {
Widget _buildOfferCard(WalaPlusRetailerModel item, bool isAr) {
return WalaPlusRetailerCard(item: item, isAr: isAr, onTap: () => _openRetailer(item));
}
Widget _buildRetailersShimmer() {
return _buildRetailersShimmerWidget(isTablet);
}
Widget _buildCategoriesShimmer() {
return SizedBox(
height: 136.0,
child: Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: ListView.separated(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: 5,
separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 14),
itemBuilder: (BuildContext _, int index) {
return SizedBox(
width: 84,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(width: 84, height: 84, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18))),
const SizedBox(height: 8),
Container(width: 65, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 4),
Container(width: 42, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
],
),
);
},
),
),
);
}
}
Widget _buildRetailersShimmerWidget(bool isTablet) {
return Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, mainAxisExtent: 235, crossAxisSpacing: 12, mainAxisSpacing: 16),
itemCount: 4,
itemBuilder: (_, __) {
return Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(height: 120, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14))),
const SizedBox(height: 8),
Container(height: 14, width: 110, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 6),
Container(height: 14, width: 80, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 6),
Row(
children: <Widget>[
Container(height: 28, width: 50, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
const SizedBox(width: 5),
Container(height: 28, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
],
),
],
),
);
},
),
);
}
class WalaPlusRetailerCard extends StatelessWidget {
final WalaPlusRetailerModel item;
final bool isAr;
final VoidCallback onTap;
const WalaPlusRetailerCard({
Key? key,
required this.item,
required this.isAr,
required this.onTap,
}) : super(key: key);
static String getDiscountBadge(WalaPlusRetailerModel item) {
if (item.offerTitle != null) {
RegExp percentRegex = RegExp(r'(\d+%)');
Match? match = percentRegex.firstMatch(item.offerTitle!);
@ -1322,15 +1370,36 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
return "";
}
Widget _buildMerchantMIcon() {
static String getTypeFilterTitle(String type) {
if (type == 'all') {
return LocaleKeys.allCategories.tr();
} else if (type == 'instore') {
return LocaleKeys.inStore.tr();
} else if (type == 'online') {
return LocaleKeys.online.tr();
}
return type.isNotEmpty ? (type[0].toUpperCase() + type.substring(1)) : type;
}
static IconData getTypeFilterIcon(String type) {
if (type == 'instore') {
return Icons.storefront_rounded;
} else if (type == 'online') {
return Icons.language_rounded;
}
return Icons.tune_rounded;
}
static Widget buildMerchantMIcon() {
return Container(color: const Color(0xFFED1C24), alignment: Alignment.center, child: "M".toText10(isBold: true, color: Colors.white));
}
Widget _buildOfferCard(WalaPlusRetailerModel item, bool isAr) {
@override
Widget build(BuildContext context) {
String? bannerUrl = (item.imageThumb != null && item.imageThumb!.isNotEmpty) ? item.imageThumb : item.image;
String? logoUrl = (item.logoThumb != null && item.logoThumb!.isNotEmpty) ? item.logoThumb : item.logo;
String discountText = _getDiscountBadge(item);
String discountText = getDiscountBadge(item);
String offerSubtitle = "";
if (discountText.isNotEmpty) {
@ -1343,8 +1412,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
offerSubtitle = item.offerTitle!.trim();
}
// type can be a comma-separated combo (e.g. "online,instore") - normalizedTypes already
// splits that into its distinct parts, so a retailer offering both ways gets a chip each.
List<String> offerTypes = item.normalizedTypes;
String usageText = "";
if (item.displayUsage.isNotEmpty) {
@ -1357,7 +1424,7 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _openRetailer(item),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
@ -1365,16 +1432,11 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
boxShadow: [BoxShadow(color: const Color(0xFF0D1720).withOpacity(0.0392), offset: const Offset(0, 5), blurRadius: 18, spreadRadius: 0)],
),
padding: const EdgeInsets.all(10),
// Hard backstop against RenderFlex overflow: if content ever needs more height than
// mainAxisExtent provides (e.g. a device with large system text scaling), this clips
// the excess instead of showing a debug overflow error. Not meant to be scrolled by
// the user - just a safety net, hence NeverScrollableScrollPhysics.
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Inset image with rounded corners
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
@ -1383,7 +1445,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
child: Stack(
fit: StackFit.expand,
children: [
// Banner Image
(bannerUrl != null && bannerUrl.isNotEmpty)
? Image.network(
bannerUrl,
@ -1391,8 +1452,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
errorBuilder: (_, __, ___) => Container(color: const Color(0xFFF1F5F9), child: const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 32)),
)
: Container(color: const Color(0xFFF1F5F9), child: const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 32)),
// Gradient overlay at bottom of image
Positioned(
bottom: 0,
left: 0,
@ -1402,8 +1461,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black.withOpacity(0.75), Colors.transparent])),
),
),
// Discount badge at top right (flush with edge, pill on start side)
if (discountText.isNotEmpty)
PositionedDirectional(
top: 20,
@ -1414,8 +1471,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
child: discountText.toText14(isBold: true, color: Colors.white),
),
),
// Brand logo & name at bottom left
PositionedDirectional(
bottom: 8,
start: 8,
@ -1427,7 +1482,7 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
height: 18,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4)),
clipBehavior: Clip.antiAlias,
child: (logoUrl != null && logoUrl.isNotEmpty) ? Image.network(logoUrl, fit: BoxFit.contain, errorBuilder: (_, __, ___) => _buildMerchantMIcon()) : _buildMerchantMIcon(),
child: (logoUrl != null && logoUrl.isNotEmpty) ? Image.network(logoUrl, fit: BoxFit.contain, errorBuilder: (_, __, ___) => buildMerchantMIcon()) : buildMerchantMIcon(),
),
const SizedBox(width: 5),
Expanded(child: (item.name ?? "").toText11(isBold: true, maxLine: 1, color: Colors.white)),
@ -1438,19 +1493,10 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
),
),
),
const SizedBox(height: 8),
// Retailer Name / Bundle Title
(item.name ?? "").toText14(isBold: true, maxlines: 1, overflow: TextOverflow.ellipsis, color: const Color(0xFF1E293B)),
// Subtitle line is always rendered (even empty) so every card reserves the same
// vertical space here, instead of shorter-content cards ending up shorter overall.
const SizedBox(height: 2),
offerSubtitle.toText14(maxlines: 1, overflow: TextOverflow.ellipsis, isBold: true, color: const Color(0xFF1E293B)),
// Badges row: Type, Location & Usage - always exactly one row, fixed height.
// Horizontally scrollable instead of wrapping to a 2nd row, so every card's badge
// section is the same height regardless of how many badges it has.
const SizedBox(height: 6),
SizedBox(
height: 28,
@ -1458,12 +1504,10 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
children: <Widget>[
// One Type Chip per distinct type - Online (green) and/or In Store (blue).
// Same pill styling (padding/border/icon size) as the retailer details screen.
for (String offerType in offerTypes) ...<Widget>[
InfoChipWidget(
text: _getTypeFilterTitle(offerType),
icon: _getTypeFilterIcon(offerType),
text: getTypeFilterTitle(offerType),
icon: getTypeFilterIcon(offerType),
backgroundColor: offerType == 'online' ? const Color(0xFFE6F7F0) : const Color(0xFFEFF6FF),
textColor: offerType == 'online' ? const Color(0xFF00A651) : const Color(0xFF2563EB),
borderColor: offerType == 'online' ? const Color(0xFF00A651) : const Color(0xFF3B82F6),
@ -1474,8 +1518,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
),
const SizedBox(width: 5),
],
// Location Badge
if (cityText.trim().isNotEmpty) ...<Widget>[
InfoChipWidget(
text: cityText,
@ -1490,8 +1532,6 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
),
const SizedBox(width: 5),
],
// Usage badge
if (usageText.trim().isNotEmpty)
InfoChipWidget(text: usageText, backgroundColor: const Color(0xFFFEF3C7), textColor: const Color(0xFFB45309), horizontalPadding: 10, verticalPadding: 5, borderRadius: 12),
],
@ -1503,75 +1543,369 @@ class _DashboardScreenForDependetState extends State<DashboardScreenForDependet>
),
);
}
}
Widget _buildRetailersShimmer() {
return Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, mainAxisExtent: 235, crossAxisSpacing: 12, mainAxisSpacing: 16),
itemCount: 4,
itemBuilder: (_, __) {
return Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(height: 120, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14))),
const SizedBox(height: 8),
Container(height: 14, width: 110, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 6),
Container(height: 14, width: 80, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 6),
Row(
children: <Widget>[
Container(height: 28, width: 50, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
const SizedBox(width: 5),
Container(height: 28, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
],
),
],
),
);
},
),
);
class FavoriteMerchantsBottomSheet extends StatefulWidget {
const FavoriteMerchantsBottomSheet({Key? key}) : super(key: key);
@override
State<FavoriteMerchantsBottomSheet> createState() => _FavoriteMerchantsBottomSheetState();
}
class _FavoriteMerchantsBottomSheetState extends State<FavoriteMerchantsBottomSheet> {
List<WalaPlusRetailerModel> favoriteRetailers = [];
bool isLoading = true;
bool isLoadingMore = false;
bool hasMore = true;
bool _isFetchingPage = false;
int currentPage = 1;
int totalPages = 1;
final int pageSize = 10;
String searchQuery = "";
Timer? _debounceTimer;
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
fetchFavorites();
}
});
}
Widget _buildCategoriesShimmer() {
return SizedBox(
height: 136.0,
child: Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: ListView.separated(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: 5,
separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 14),
itemBuilder: (BuildContext _, int index) {
return SizedBox(
width: 84,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(width: 84, height: 84, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18))),
const SizedBox(height: 8),
Container(width: 65, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 4),
Container(width: 42, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
],
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _scrollListener() {
if (!_scrollController.hasClients) return;
double maxScroll = _scrollController.position.maxScrollExtent;
double currentScroll = _scrollController.position.pixels;
if (currentScroll >= maxScroll - 35) {
if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) {
fetchNextPage();
}
}
}
String? _getUserReferenceForFavorites() {
String ref = OffersAndDiscountsApiClient().resolveUserReference();
if (ref.trim().isNotEmpty) return ref.trim();
String? userName = AppState().getUserName;
if (userName != null && userName.trim().isNotEmpty) return userName.trim();
return null;
}
Future<void> fetchFavorites({String? query}) async {
if (query != null) {
searchQuery = query;
}
setState(() {
isLoading = true;
currentPage = 1;
hasMore = true;
});
try {
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
String? userRef = _getUserReferenceForFavorites();
debugPrint("Fetching favorite retailers: query='$queryParam', userReference='$userRef', page=1, per_page=$pageSize");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: "",
page: 1,
perPage: pageSize,
offerType: null,
query: queryParam,
sortBy: null,
radius: null,
minDiscount: null,
context: null,
userReference: userRef,
);
favoriteRetailers = response?.retailers ?? <WalaPlusRetailerModel>[];
for (WalaPlusRetailerModel r in favoriteRetailers) {
r.isFavorite = true;
}
currentPage = 1;
totalPages = response?.pagesCount ?? 1;
hasMore = currentPage < totalPages && (response?.retailers?.isNotEmpty ?? false);
} catch (e) {
debugPrint("Error fetching favorite retailers: $e");
favoriteRetailers = <WalaPlusRetailerModel>[];
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
Future<void> fetchNextPage() async {
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
_isFetchingPage = true;
setState(() {
isLoadingMore = true;
});
try {
int nextPage = currentPage + 1;
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
String? userRef = _getUserReferenceForFavorites();
debugPrint("Fetching favorite retailers next page: $nextPage of $totalPages, query: '$queryParam', userReference='$userRef'");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: "",
page: nextPage,
perPage: pageSize,
offerType: null,
query: queryParam,
sortBy: null,
radius: null,
minDiscount: null,
context: null,
userReference: userRef,
);
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? <WalaPlusRetailerModel>[];
if (newRetailers.isNotEmpty) {
for (WalaPlusRetailerModel r in newRetailers) {
r.isFavorite = true;
}
currentPage = nextPage;
totalPages = response?.pagesCount ?? totalPages;
hasMore = currentPage < totalPages;
favoriteRetailers.addAll(newRetailers);
} else {
hasMore = false;
}
} catch (e) {
debugPrint("Error fetching next page of favorite retailers: $e");
} finally {
_isFetchingPage = false;
if (mounted) {
setState(() {
isLoadingMore = false;
});
}
}
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
searchQuery = query;
if (query.trim().isEmpty) {
fetchFavorites(query: "");
} else {
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
fetchFavorites(query: query.trim());
});
}
}
void _openRetailer(WalaPlusRetailerModel item) async {
await Navigator.pushNamed(context, AppRoutes.walaPlusRetailerDetails, arguments: item);
if (mounted) {
await fetchFavorites(query: _searchController.text);
}
}
@override
Widget build(BuildContext context) {
bool isAr = AppState().isArabic(context);
return Container(
height: MediaQuery.of(context).size.height * 0.94,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 44,
height: 4.5,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)),
),
),
);
},
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => Navigator.pop(context),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Icon(Icons.arrow_back_ios_new_rounded, size: 20, color: Color(0xFF1E293B)),
),
),
const SizedBox(width: 8),
LocaleKeys.favoriteOffers.tr().toText18(weight: FontWeight.w600, color: const Color(0xFF1E293B)),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.favorite_rounded, size: 22, color: Colors.black),
const SizedBox(width: 10),
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.pop(context),
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xFFF1F5F9)),
child: const Icon(Icons.close_rounded, size: 18, color: Color(0xFF1E293B)),
),
),
],
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
width: double.infinity,
height: 70,
padding: const EdgeInsets.only(top: 14, right: 16, bottom: 14, left: 16),
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8E8E8), width: 1.0),
boxShadow: [BoxShadow(color: const Color(0xFF000000).withOpacity(0.0314), offset: const Offset(0, 2), blurRadius: 5, spreadRadius: 0)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
LocaleKeys.search.tr().toText12(isBold: true, color: const Color(0xFF2A333C)),
const SizedBox(height: 2),
TextField(
controller: _searchController,
onChanged: _onSearchChanged,
onSubmitted: (String val) {
_debounceTimer?.cancel();
fetchFavorites(query: val.trim());
},
textInputAction: TextInputAction.search,
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
hintText: LocaleKeys.searchItems.tr(),
hintStyle: const TextStyle(color: Color(0xFFACAFB1), fontSize: 14, fontWeight: FontWeight.w400, height: 1.2),
border: InputBorder.none,
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (BuildContext context, TextEditingValue value, Widget? child) {
if (value.text.isEmpty) return const SizedBox.shrink();
return InkWell(
child: const Icon(Icons.clear, size: 18, color: Color(0xFF848486)),
onTap: () {
_searchController.clear();
_onSearchChanged("");
},
);
},
),
suffixIconConstraints: const BoxConstraints(maxWidth: 24, maxHeight: 28),
),
style: const TextStyle(color: Color(0xFF2A333C), fontSize: 14, fontWeight: FontWeight.w400),
),
],
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => fetchFavorites(query: _searchController.text),
color: MyColors.textMixColor,
child: isLoading
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _buildRetailersShimmerWidget(isTablet),
)
: favoriteRetailers.isNotEmpty
? SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 4 : 2,
mainAxisExtent: 235,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
),
itemCount: favoriteRetailers.length,
itemBuilder: (context, index) {
return WalaPlusRetailerCard(
item: favoriteRetailers[index],
isAr: isAr,
onTap: () => _openRetailer(favoriteRetailers[index]),
);
},
),
if (isLoadingMore) ...[
16.height,
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Image.asset("assets/images/logos/loading_mohemm_logo.gif", height: 48.0, width: 48.0),
),
),
],
const SizedBox(height: 20),
],
),
)
: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
child: Padding(
padding: const EdgeInsets.only(top: 80),
child: Center(
child: LocaleKeys.noOffersAvailable.tr().toText14(color: const Color(0xFF64748B)),
),
),
),
),
),
],
),
),
),
);
}
}

Loading…
Cancel
Save