From d23910351fe193e52d9abda6138b40e3a1d26a88 Mon Sep 17 00:00:00 2001 From: Sultan khan Date: Mon, 19 Oct 2026 16:40:51 +0300 Subject: [PATCH] bottomsheet added --- assets/langs/ar-SA.json | 3 +- assets/langs/en-US.json | 3 +- lib/generated/codegen_loader.g.dart | 6 +- lib/generated/locale_keys.g.dart | 1 + .../dashboard_screen_for_dependet.dart | 700 +++++++++++++----- 5 files changed, 526 insertions(+), 187 deletions(-) diff --git a/assets/langs/ar-SA.json b/assets/langs/ar-SA.json index e9f50559..429b17f2 100644 --- a/assets/langs/ar-SA.json +++ b/assets/langs/ar-SA.json @@ -829,5 +829,6 @@ "merchantWebsiteNotAvailable": "موقع التاجر غير متوفر", "locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة", "locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة", - "favoriteMerchant": "العروض المفضلة" + "favoriteMerchant": "العروض المفضلة", + "favoriteOffers": "العروض المفضلة" } diff --git a/assets/langs/en-US.json b/assets/langs/en-US.json index 04920049..988c8ac2 100644 --- a/assets/langs/en-US.json +++ b/assets/langs/en-US.json @@ -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" } diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart index da4048b3..1715b2cf 100644 --- a/lib/generated/codegen_loader.g.dart +++ b/lib/generated/codegen_loader.g.dart @@ -845,7 +845,8 @@ class CodegenLoader extends AssetLoader{ "merchantWebsiteNotAvailable": "موقع التاجر غير متوفر", "locationServicesRequiredForNearestOffers": "يجب تفعيل خدمة الموقع لعرض العروض القريبة", "locationPermissionRequiredForNearestOffers": "يجب منح إذن الموقع لعرض العروض القريبة", - "favoriteMerchant": "العروض المفضلة" + "favoriteMerchant": "العروض المفضلة", + "favoriteOffers": "العروض المفضلة" }; static const Map _en_US = { "mohemm": "Mohemm", @@ -1678,7 +1679,8 @@ static const Map _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> mapLocales = {"ar_SA": _ar_SA, "en_US": _en_US}; } diff --git a/lib/generated/locale_keys.g.dart b/lib/generated/locale_keys.g.dart index 9cdb924a..1114961f 100644 --- a/lib/generated/locale_keys.g.dart +++ b/lib/generated/locale_keys.g.dart @@ -818,5 +818,6 @@ abstract class LocaleKeys { static const locationServicesRequiredForNearestOffers = 'locationServicesRequiredForNearestOffers'; static const locationPermissionRequiredForNearestOffers = 'locationPermissionRequiredForNearestOffers'; static const favoriteMerchant = 'favoriteMerchant'; + static const favoriteOffers = 'favoriteOffers'; } diff --git a/lib/ui/landing/dashboard_screen_for_dependet.dart b/lib/ui/landing/dashboard_screen_for_dependet.dart index 3c3da7a3..a28807e4 100644 --- a/lib/ui/landing/dashboard_screen_for_dependet.dart +++ b/lib/ui/landing/dashboard_screen_for_dependet.dart @@ -57,7 +57,6 @@ class _DashboardScreenForDependetState extends State String _filterDistance = '5km'; 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 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 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 }); 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 ?? []; - 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 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 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 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 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 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 // 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) @@ -1177,6 +1118,21 @@ class _DashboardScreenForDependetState extends State ); } + void _showFavoritesBottomSheet() async { + await showModalBottomSheet( + 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; @@ -1275,7 +1231,99 @@ class _DashboardScreenForDependetState extends State 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: [ + 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: [ + 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: [ + 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!); @@ -1293,15 +1341,36 @@ class _DashboardScreenForDependetState extends State 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) { @@ -1314,8 +1383,6 @@ class _DashboardScreenForDependetState extends State 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 offerTypes = item.normalizedTypes; String usageText = ""; if (item.displayUsage.isNotEmpty) { @@ -1328,7 +1395,7 @@ class _DashboardScreenForDependetState extends State return InkWell( borderRadius: BorderRadius.circular(20), - onTap: () => _openRetailer(item), + onTap: onTap, child: Container( decoration: BoxDecoration( color: Colors.white, @@ -1336,16 +1403,11 @@ class _DashboardScreenForDependetState extends State 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( @@ -1354,7 +1416,6 @@ class _DashboardScreenForDependetState extends State child: Stack( fit: StackFit.expand, children: [ - // Banner Image (bannerUrl != null && bannerUrl.isNotEmpty) ? Image.network( bannerUrl, @@ -1362,8 +1423,6 @@ class _DashboardScreenForDependetState extends State 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, @@ -1373,8 +1432,6 @@ class _DashboardScreenForDependetState extends State 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, @@ -1385,8 +1442,6 @@ class _DashboardScreenForDependetState extends State child: discountText.toText14(isBold: true, color: Colors.white), ), ), - - // Brand logo & name at bottom left PositionedDirectional( bottom: 8, start: 8, @@ -1398,7 +1453,7 @@ class _DashboardScreenForDependetState extends State 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)), @@ -1409,19 +1464,10 @@ class _DashboardScreenForDependetState extends State ), ), ), - 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, @@ -1429,12 +1475,10 @@ class _DashboardScreenForDependetState extends State scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), children: [ - // 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) ...[ 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), @@ -1445,8 +1489,6 @@ class _DashboardScreenForDependetState extends State ), const SizedBox(width: 5), ], - - // Location Badge if (cityText.trim().isNotEmpty) ...[ InfoChipWidget( text: cityText, @@ -1461,8 +1503,6 @@ class _DashboardScreenForDependetState extends State ), 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), ], @@ -1474,75 +1514,369 @@ class _DashboardScreenForDependetState extends State ), ); } +} - 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: [ - 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: [ - 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 createState() => _FavoriteMerchantsBottomSheetState(); +} + +class _FavoriteMerchantsBottomSheetState extends State { + List 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: [ - 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 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 ?? []; + 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 = []; + } finally { + if (mounted) { + setState(() { + isLoading = false; + }); + } + } + } + + Future 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 newRetailers = response?.retailers ?? []; + 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( + 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)), + ), + ), + ), + ), + ), + ], + ), ), ), ); } } +