import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:geolocator/geolocator.dart'; import 'package:mohem_flutter_app/api/login_api_client.dart'; import 'package:mohem_flutter_app/api/offers_and_discounts_api_client.dart'; import 'package:mohem_flutter_app/app_state/app_state.dart'; import 'package:mohem_flutter_app/classes/colors.dart'; import 'package:mohem_flutter_app/classes/consts.dart'; import 'package:mohem_flutter_app/classes/utils.dart'; import 'package:mohem_flutter_app/config/routes.dart'; import 'package:mohem_flutter_app/extensions/int_extensions.dart'; import 'package:mohem_flutter_app/extensions/string_extensions.dart'; import 'package:mohem_flutter_app/generated/locale_keys.g.dart'; import 'package:mohem_flutter_app/main.dart'; import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_category_model.dart'; import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart'; import 'package:mohem_flutter_app/models/post_params_model.dart'; import 'package:mohem_flutter_app/widgets/bottom_sheets/confirm_bottom_sheet.dart'; import 'package:mohem_flutter_app/widgets/button/default_button.dart'; import 'package:mohem_flutter_app/widgets/info_chip_widget.dart'; import 'package:mohem_flutter_app/widgets/location/Location.dart' as location; import 'package:mohem_flutter_app/widgets/toast/app_toast.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shimmer/shimmer.dart'; class DashboardScreenForDependet extends StatefulWidget { const DashboardScreenForDependet({Key? key}) : super(key: key); @override State createState() => _DashboardScreenForDependetState(); } class _DashboardScreenForDependetState extends State { List retailersList = []; List _filteredRetailersList = []; List categoriesList = []; String currentCategoryId = ""; String selectedTypeFilter = "all"; // Subcategories of whichever main category is currently selected - fetched via // getWalaPlusCategories(parentId: ...) whenever a main category card is tapped. Empty/cleared // when "All Categories" (or nothing) is selected, since there's no specific parent to ask for. List subcategoriesList = []; bool isSubcategoriesLoading = false; String? _selectedSubcategoryId; // Filters bottom sheet state - all fully wired to the API now. Availability drives // WP_Offer_Type (via selectedTypeFilter), _filterSortBy drives WP_Sort_By ('nearest' // requires WP_Geo_Location, see _ensureLocationReadyForNearestSort), _filterDistance drives // WP_Radius (null when "any"), _filterMinDiscount drives WP_Min_Discount (null when "any"). // Committed on "Apply filters"/"Reset"; persisted here so reopening the sheet shows the // last-applied selection (availability's source of truth is selectedTypeFilter itself). String _filterSortBy = 'nearest'; String _filterDistance = '5km'; String _filterMinDiscount = 'any'; bool isNearestOffers = true; bool isFavoriteOnly = false; bool isLoading = true; bool isLoadingMore = false; bool isCategoriesLoading = true; 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(); 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(); _scrollController.addListener(_scrollListener); _loadDependentName(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (mounted) { await _saveDependentSessionToPrefs(); await _registerMobileLoginInfo(); await fetchCategories(); await fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId); } }); } Future _registerMobileLoginInfo() async { if (_hasRegisteredMobileLoginInfo) return; if (!ApiConsts.isDependetLogin) return; _hasRegisteredMobileLoginInfo = true; try { int loginType = AppState().dependentLoginType ?? 1; String nationalId = AppState().getUserName ?? ""; String dob = AppState().dependentDob ?? ""; String name = AppState().dependentName ?? ""; String mobile = AppState().postParamsObject?.mobileNumber ?? ""; String tokenID = (AppState().postParamsObject?.tokenID != null && AppState().postParamsObject!.tokenID!.isNotEmpty) ? AppState().postParamsObject!.tokenID! : (AppState().authenticationTokenID ?? ""); if (tokenID.isEmpty) { tokenID = await Utils.getStringFromPrefs("P_DEP_TOKEN_ID"); } int sessionId = AppState().postParamsObject?.pSessionId ?? 0; String email = AppState().postParamsObject?.pEmailAddress ?? ""; String deviceToken = AppState().getIsHuawei ? AppState().getHuaweiPushToken : (AppState().getDeviceToken ?? ""); await LoginApiClient().insertMobileLoginInfoDependent( loginType: loginType, nationalId: nationalId, dob: dob, dependentName: name, mobileNumber: mobile, tokenID: tokenID, sessionId: sessionId, email: email, deviceToken: deviceToken, context: context, ); } catch (e) { if (kDebugMode) { print("Error calling insertMobileLoginInfoDependent: $e"); } } } void _loadDependentName() { if (AppState().dependentName != null && AppState().dependentName!.isNotEmpty && mounted) { setState(() {}); } } String _cleanDependentName(String name) { 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(); _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; // Trigger only when user scrolls to within 35px of the very bottom if (currentScroll >= maxScroll - 35) { if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) { fetchNextPage(); } } } Future fetchCategories() async { setState(() { isCategoriesLoading = true; }); try { List list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context); if (list.isEmpty) { await Future.delayed(const Duration(milliseconds: 600)); list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context); } if (mounted) { List fullList = []; bool hasAll = list.any((c) => (c.id == "all" || c.name?.toLowerCase() == "all" || c.name == "الجميع" || c.name == "الكل")); if (!hasAll) { fullList.add(WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr())); } fullList.addAll(list); setState(() { categoriesList = fullList; if (currentCategoryId.isEmpty) { currentCategoryId = "all"; } }); } } catch (e) { debugPrint("Error fetching WalaPlus categories (attempt 1): $e"); try { await Future.delayed(const Duration(milliseconds: 600)); List retryList = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context); if (mounted && retryList.isNotEmpty) { List fullList = []; bool hasAll = retryList.any((c) => (c.id == "all" || c.name?.toLowerCase() == "all" || c.name == "الجميع" || c.name == "الكل")); if (!hasAll) { fullList.add(WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr())); } fullList.addAll(retryList); setState(() { categoriesList = fullList; if (currentCategoryId.isEmpty) { currentCategoryId = "all"; } }); return; } } catch (retryEx) { debugPrint("Error fetching WalaPlus categories (attempt 2): $retryEx"); } if (mounted && categoriesList.isEmpty) { setState(() { categoriesList = [WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr())]; if (currentCategoryId.isEmpty) { currentCategoryId = "all"; } }); } } finally { if (mounted) { setState(() { isCategoriesLoading = false; }); } } } // Fetches the subcategories under a main category (WP_ParentId). "All Categories" (or no // category) has no specific parent, so that just clears the list instead of calling the API. Future fetchSubcategories(String? parentId) async { if (parentId == null || parentId.isEmpty || parentId == "all") { setState(() { subcategoriesList = []; _selectedSubcategoryId = null; }); return; } setState(() { isSubcategoriesLoading = true; }); try { List list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context, parentId: parentId); if (mounted) { setState(() { subcategoriesList = list; _selectedSubcategoryId = null; }); } } catch (e) { debugPrint("Error fetching WalaPlus subcategories: $e"); if (mounted) { setState(() { subcategoriesList = []; }); } } finally { if (mounted) { setState(() { isSubcategoriesLoading = false; }); } } } Future fetchOffers({String? categoryId, String? query}) async { String targetCategoryId = categoryId ?? (currentCategoryId.isEmpty ? "all" : currentCategoryId); if (query != null) { searchQuery = query; } bool wantsNearest = _filterSortBy == 'nearest'; if (wantsNearest && !await _ensureLocationReadyForNearestSort()) { return; } setState(() { isLoading = true; currentPage = 1; hasMore = true; }); try { String catParam = (isFavoriteOnly || targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId; String? offerTypeParam = isFavoriteOnly ? null : 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"); WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers( categoryId: catParam, page: 1, perPage: pageSize, offerType: offerTypeParam, query: queryParam, sortBy: isFavoriteOnly ? null : _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, ); retailersList = response?.retailers ?? []; if (isFavoriteOnly) { for (var r in retailersList) { r.isFavorite = true; } } _filteredRetailersList = retailersList; currentPage = 1; totalPages = response?.pagesCount ?? 1; hasMore = currentPage < totalPages && (response?.retailers?.isNotEmpty ?? false); } catch (e) { print("Error fetching WalaPlus retailers: $e"); retailersList = []; _filteredRetailersList = []; } finally { if (mounted) { setState(() { isLoading = false; }); } } } Future fetchNextPage() async { if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return; bool wantsNearest = _filterSortBy == 'nearest'; if (wantsNearest && !await _ensureLocationReadyForNearestSort()) { return; } _isFetchingPage = true; setState(() { isLoadingMore = true; }); try { int nextPage = currentPage + 1; String catParam = (isFavoriteOnly || currentCategoryId == "all" || currentCategoryId == "0") ? "" : currentCategoryId; String? offerTypeParam = isFavoriteOnly ? null : 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); print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam', userReference='$userRef'"); WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers( categoryId: catParam, page: nextPage, perPage: pageSize, offerType: offerTypeParam, query: queryParam, sortBy: isFavoriteOnly ? null : _filterSortBy, radius: radiusParam, minDiscount: minDiscountParam, context: isFavoriteOnly ? null : context, userReference: userRef, ); 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; retailersList.addAll(newRetailers); _filteredRetailersList = retailersList; } else { hasMore = false; } } catch (e) { print("Error fetching next page of WalaPlus retailers: $e"); } finally { _isFetchingPage = false; if (mounted) { setState(() { isLoadingMore = false; }); } } } Future _onRefresh() async { await Future.wait(>[fetchCategories(), fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId, query: _searchController.text)]); } // '5km'/'10km'/'25km' -> 5/10/25 for WP_Radius; 'any' -> null (omit the param - no distance cap). int? _radiusFromFilterDistance(String distance) { if (distance == 'any') return null; return int.tryParse(distance.replaceAll('km', '')); } // WP_Sort_By=nearest requires WP_Geo_Location to be passed alongside it, so before fetching // with "Nearest" selected we make sure location services + permission are actually available. // Mirrors the existing attendance-flow location-permission pattern (see // mark_attendance_widget.dart's checkHuaweiLocationPermission): services-off -> prompt to open // settings, permission-not-granted -> request it, still denied -> toast. Never auto-retries the // fetch - the user must grant access and trigger the fetch again themselves. Future _ensureLocationReadyForNearestSort() async { Completer enabledCompleter = Completer(); location.Location.isEnabled((bool isEnabled) { if (!enabledCompleter.isCompleted) enabledCompleter.complete(isEnabled); }); bool isEnabled = await enabledCompleter.future; if (!isEnabled) { if (mounted) { ConfirmBottomSheet.show( context, message: LocaleKeys.locationServicesRequiredForNearestOffers.tr(), confirmText: LocaleKeys.openSettings.tr(), showCancelButton: false, onConfirm: () async { await Geolocator.openLocationSettings(); }, ); } return false; } Completer permissionCompleter = Completer(); location.Location.havePermission((bool hasPermission) { if (!permissionCompleter.isCompleted) permissionCompleter.complete(hasPermission); }); bool hasPermission = await permissionCompleter.future; if (!hasPermission) { bool granted = await _requestLocationPermission(); if (!granted) { AppToast.show(LocaleKeys.locationPermissionRequiredForNearestOffers.tr()); return false; } } return true; } Future _requestLocationPermission() async { Map result = await [Permission.location].request(); return result[Permission.location] == PermissionStatus.granted || result[Permission.locationAlways] == PermissionStatus.granted; } void _onSearchChanged(String query) { _debounceTimer?.cancel(); searchQuery = query; if (query.trim().isEmpty) { fetchOffers(query: ""); } else { _debounceTimer = Timer(const Duration(milliseconds: 1000), () { fetchOffers(query: query.trim()); }); } } 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(() {}); } } } Future _saveDependentSessionToPrefs() async { if (!ApiConsts.isDependetLogin) return; try { await Utils.saveStringFromPrefs("P_DEP_IS_LOGGED_IN", "true"); String nationalId = AppState().getUserName ?? ""; if (nationalId.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEP_NATIONAL_ID", nationalId); } String dob = AppState().dependentDob ?? ""; if (dob.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEP_BIRTH_DATE", dob); } String name = AppState().dependentName ?? ""; if (name.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEPENDENT_NAME", name); } String mobile = AppState().postParamsObject?.mobileNumber ?? ""; if (mobile.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEP_MOBILE_NUMBER", mobile); } String tokenID = (AppState().postParamsObject?.tokenID != null && AppState().postParamsObject!.tokenID!.isNotEmpty) ? AppState().postParamsObject!.tokenID! : (AppState().authenticationTokenID ?? ""); if (tokenID.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEP_TOKEN_ID", tokenID); } String logInTokenID = AppState().postParamsObject?.logInTokenID ?? ""; if (logInTokenID.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEP_LOGIN_TOKEN_ID", logInTokenID); } String authTokenID = AppState().authenticationTokenID ?? ""; if (authTokenID.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEP_AUTHENTICATION_TOKEN_ID", authTokenID); } int loginType = AppState().dependentLoginType ?? 1; await Utils.saveStringFromPrefs("P_DEP_LOGIN_TYPE", loginType.toString()); String deviceToken = AppState().getIsHuawei ? AppState().getHuaweiPushToken : (AppState().getDeviceToken ?? ""); if (deviceToken.isNotEmpty) { await Utils.saveStringFromPrefs("P_DEP_DEVICE_TOKEN", deviceToken); } } catch (e) { if (kDebugMode) print("Error saving dependent session to prefs: $e"); } } Future _clearDependentPrefs() async { await Utils.removeStringFromPrefs("P_DEP_IS_LOGGED_IN"); await Utils.removeStringFromPrefs("P_DEP_NATIONAL_ID"); await Utils.removeStringFromPrefs("P_DEP_BIRTH_DATE"); await Utils.removeStringFromPrefs("P_DEP_TOKEN_ID"); await Utils.removeStringFromPrefs("P_DEP_LOGIN_TOKEN_ID"); await Utils.removeStringFromPrefs("P_DEP_AUTHENTICATION_TOKEN_ID"); await Utils.removeStringFromPrefs("P_DEP_MOBILE_NUMBER"); await Utils.removeStringFromPrefs("P_DEPENDENT_NAME"); await Utils.removeStringFromPrefs("P_DEP_LOGIN_TYPE"); } void _performLogout() { ConfirmBottomSheet.show( context, message: LocaleKeys.areYouSureWantToLogOut.tr(), confirmText: LocaleKeys.yes.tr(), cancelText: LocaleKeys.no.tr(), isDestructive: false, onConfirm: () async { if (ApiConsts.isDependetLogin) { await _clearDependentPrefs(); } ApiConsts.selectedMemberType = MemberType.employee; ApiConsts.ISDependetLogin = false; AppState().setDependentName = null; AppState().setDependentDob = null; AppState().setDependentLoginType = null; AppState().setWalaplusDepInfoModel = null; AppState().setPostParamsInitConfig(); if (mounted) { Navigator.pushNamedAndRemoveUntil(context, AppRoutes.login, (Route route) => false); } }, ); } Future _changeLanguage(Locale newLocale) async { if (context.locale == newLocale) return; await context.setLocale(newLocale); PostParamsModel? obj = AppState().postParamsObject; if (obj != null) { obj.languageID = (newLocale.languageCode == "ar" ? 1 : 2); AppState().setPostParamsModel(obj); } if (mounted) { setState(() {}); await fetchCategories(); await fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId); } } Widget _buildLanguageIconButton(bool isAr) { return Tooltip( message: isAr ? 'English' : 'العربية', child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { if (isAr) { _changeLanguage(const Locale("en", "US")); } else { _changeLanguage(const Locale("ar", "SA")); } }, child: const Padding(padding: EdgeInsets.all(3), child: Icon(Icons.language_rounded, size: 24, color: Color(0xFF1E293B))), ), ); } @override Widget build(BuildContext context) { bool isAr = AppState().isArabic(context); return Scaffold( backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, elevation: 0, surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, titleSpacing: 0, title: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ if (!ApiConsts.isDependetLogin) ...[ InkWell( borderRadius: BorderRadius.circular(10), onTap: () { if (Navigator.canPop(context)) { Navigator.pop(context); } else { _performLogout(); } }, 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), ], SvgPicture.asset("assets/icons/mazaya_brand.svg", height: 40, fit: BoxFit.contain), ], ), Row( mainAxisSize: MainAxisSize.min, children: [ if (ApiConsts.isDependetLogin) _buildLanguageIconButton(isAr), if (!ApiConsts.isDependetLogin) ...[ const SizedBox(width: 4), Tooltip( message: LocaleKeys.addDependents.tr(), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { Navigator.pushNamed(context, AppRoutes.familyMembers, arguments: {'fromMazaya': true}); }, child: Padding( padding: const EdgeInsets.all(3), child: SvgPicture.asset('assets/icons/ic_dependents_top.svg', width: 24, height: 24, colorFilter: const ColorFilter.mode(Color(0xFF1E293B), BlendMode.srcIn)), ), ), ), ], const SizedBox(width: 4), Tooltip( 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)), ), ), ), if (ApiConsts.isDependetLogin) ...[ const SizedBox(width: 4), InkWell( borderRadius: BorderRadius.circular(8), onTap: _performLogout, child: Padding(padding: const EdgeInsets.all(3), child: SvgPicture.asset('assets/images/drawer/logout.svg', height: 20, width: 20)), ), ], ], ), ], ), ), ), body: SafeArea( child: RefreshIndicator( onRefresh: _onRefresh, color: MyColors.textMixColor, child: SingleChildScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Welcome greeting - same "Welcome Back" label + large bold name design used // on the last-login screen, shown for dependent logins only. if (ApiConsts.isDependetLogin && (AppState().dependentName ?? "").isNotEmpty) ...[ LocaleKeys.welcomeBack.tr().toText12(), _cleanDependentName(AppState().dependentName!).toText18(isBold: true), const SizedBox(height: 16), ], // 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), // Categories Section if (categoriesList.isNotEmpty) _buildCategoriesList(isAr) else _buildCategoriesShimmer(), // Type Filter Chips (All, In Store, Online) _buildFiltersRow(isAr), const SizedBox(height: 16), ], // Offers Grid if (isLoading) _buildRetailersShimmer() else if (_filteredRetailersList.isNotEmpty) ...[ GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), // Fixed pixel height instead of childAspectRatio - the card's content is // fixed in absolute pixels, not proportional to screen width, so an aspect // ratio made the computed cell height inconsistent across device sizes. // _buildOfferCard now also standardizes every card's content height (the // subtitle line and badges row are always reserved/fixed-height, not // conditional/wrapping), so 235 is a real, stable fit - not a worst-case // guess - with a non-scrollable SingleChildScrollView backstop in the card // itself for the rare case a device still needs a bit more (e.g. large // system text scaling), which clips instead of overflowing. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, mainAxisExtent: 235, crossAxisSpacing: 12, mainAxisSpacing: 16), itemCount: _filteredRetailersList.length, itemBuilder: (context, index) { return _buildOfferCard(_filteredRetailersList[index], isAr); }, ), 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))), ], ] else Padding(padding: const EdgeInsets.only(top: 40), child: Center(child: LocaleKeys.noOffersAvailable.tr().toText14(color: const Color(0xFF64748B)))), ], ), ), ), ), ); } Widget _buildSearchBar(bool isAr) { return 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(); fetchOffers(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, // Listens to the controller directly (rather than relying on _onSearchChanged's // own setState elsewhere) so the clear icon reliably shows/hides on every // keystroke, independent of the search debounce/fetch timing. 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)), // padding: EdgeInsets.zero, // constraints: const BoxConstraints(), onTap: () { _searchController.clear(); _onSearchChanged(""); }, ); }, ), // max, not min - this search bar's outer Container is a fixed height (70), so the // icon must never be forced taller than the TextField's own natural height or it // overflows the available space. suffixIconConstraints: const BoxConstraints(maxWidth: 24, maxHeight: 28), ), style: const TextStyle(color: Color(0xFF2A333C), fontSize: 14, fontWeight: FontWeight.w400), ), ], ), ); } Widget _buildBrowseCategoriesHeader(bool isAr) { return Padding(padding: const EdgeInsets.only(top: 22, bottom: 14), child: LocaleKeys.browseCategories.tr().toText18(isBold: true, color: const Color(0xFF1E293B))); } Widget _buildCategoriesList(bool isAr) { return SizedBox( height: 136.0, width: double.infinity, child: ListView.separated( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), padding: EdgeInsets.zero, itemCount: categoriesList.length, separatorBuilder: (_, __) => const SizedBox(width: 14), itemBuilder: (BuildContext context, int index) { WalaPlusCategoryModel item = categoriesList[index]; bool isSelected = (item.id == "all" && (currentCategoryId == "all" || currentCategoryId.isEmpty)) || (currentCategoryId.isNotEmpty && currentCategoryId == item.id); String title = (item.id == "all") ? LocaleKeys.allCategories.tr() : (item.name ?? ""); return InkWell( borderRadius: BorderRadius.circular(18), onTap: () { if (currentCategoryId != item.id) { setState(() { currentCategoryId = item.id ?? ""; }); fetchOffers(categoryId: currentCategoryId); fetchSubcategories(currentCategoryId); } }, child: SizedBox( width: 84, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _buildCategoryCard(item, isSelected), const SizedBox(height: 8), title.toText13( // isBold: true, color: const Color(0xFF1E293B), maxlines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), ); }, ), ); } // Replaces the old All/In Store/Online chip row: a fixed "Filters" chip (opens the filters // bottom sheet) + a divider, then a horizontally scrollable row of subcategory chips fetched // from the API for whichever main category is currently selected (see fetchSubcategories). Widget _buildFiltersRow(bool isAr) { // "All Categories" (or nothing selected) has no subcategories to show at all - only a // specific main category, once it has actually loaded some, gets the divider + chip row. bool showSubcategoriesRow = currentCategoryId.isNotEmpty && currentCategoryId != "all" && (isSubcategoriesLoading || subcategoriesList.isNotEmpty); return Padding( padding: const EdgeInsets.only(top: 14, bottom: 2), child: SizedBox( height: 38, child: Row( children: [ InkWell( borderRadius: BorderRadius.circular(6), onTap: _showFiltersBottomSheet, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), alignment: Alignment.center, decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: MyColors.lightGreyEAColor), child: Row( mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset("assets/icons/filter_icon.svg", width: 14, height: 14, colorFilter: const ColorFilter.mode(MyColors.black, BlendMode.srcIn)), const SizedBox(width: 5), LocaleKeys.filters.tr().toText12(color: MyColors.black), ], ), ), ), if (showSubcategoriesRow) ...[ Container(margin: const EdgeInsets.symmetric(horizontal: 10), width: 1, height: 20, color: const Color(0xFFE2E8F0)), Expanded( child: isSubcategoriesLoading ? Shimmer.fromColors( baseColor: const Color(0xFFE2E8F0), highlightColor: const Color(0xFFF8FAFC), child: ListView.separated( scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), itemCount: 4, separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 8), itemBuilder: (BuildContext _, int __) => Container(width: 70, height: 32, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))), ), ) : ListView.separated( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), // +1 for the leading "All" chip (clears the subcategory selection, // falling back to just the parent main category). itemCount: subcategoriesList.length + 1, separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 8), itemBuilder: (BuildContext context, int index) { bool isAllChip = index == 0; WalaPlusCategoryModel? subcategory = isAllChip ? null : subcategoriesList[index - 1]; bool isSelected = isAllChip ? _selectedSubcategoryId == null : _selectedSubcategoryId == subcategory!.id; String label = isAllChip ? LocaleKeys.allCategories.tr() : (subcategory!.name ?? ""); return InkWell( borderRadius: BorderRadius.circular(6), onTap: () { setState(() { _selectedSubcategoryId = isAllChip ? null : (isSelected ? null : subcategory!.id); }); // Selecting a subcategory filters offers to just that subcategory; // "All" (or deselecting) falls back to the parent main category. fetchOffers(categoryId: _selectedSubcategoryId ?? currentCategoryId); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), alignment: Alignment.center, decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: isSelected ? MyColors.darkIconColor : MyColors.lightGreyEAColor), child: label.toText12(color: isSelected ? MyColors.white : MyColors.black), ), ); }, ), ), ], ], ), ), ); } // Matches the shared Filters bottom sheet design. All four sections are fully wired to the // API: Availability -> WP_Offer_Type (via selectedTypeFilter), Sort by -> WP_Sort_By, Distance // -> WP_Radius, Minimum discount -> WP_Min_Discount. "Any"/"All" maps to null (param omitted). void _showFiltersBottomSheet() { String draftAvailability = selectedTypeFilter; String draftSortBy = _filterSortBy; String draftDistance = _filterDistance; String draftMinDiscount = _filterMinDiscount; const Color darkBg = Color(0xFF202B33); const Color borderColor = Color(0xFFE2E8F0); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (BuildContext sheetContext) { return StatefulBuilder( builder: (BuildContext context, StateSetter setSheetState) { Widget buildOption(String label, bool isSelected, VoidCallback onTap, {IconData? icon}) { return Expanded( child: InkWell( borderRadius: BorderRadius.circular(8), onTap: onTap, child: Container( height: 44, alignment: Alignment.center, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: isSelected ? darkBg : MyColors.lightGreyEAColor, border: Border.all(color: isSelected ? darkBg : MyColors.lightGreyEAColor), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[Icon(icon, size: 14, color: isSelected ? Colors.white : MyColors.black), const SizedBox(width: 5)], Flexible(child: label.toText13(isBold: true, color: isSelected ? Colors.white : MyColors.black, maxlines: 1)), if (isSelected) ...[const SizedBox(width: 5), const Icon(Icons.check, size: 14, color: Colors.white)], ], ), ), ), ); } Widget sectionHeader(String title, {String? hint}) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [title.toText14(isBold: true, color: darkBg), if (hint != null) hint.toText12(color: const Color(0xFF94A3B8))], ); } return SafeArea( top: false, child: Container( padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24))), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 44, height: 4.5, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)), ), ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ LocaleKeys.filters.tr().toText20(isBold: true, color: darkBg), InkWell( borderRadius: BorderRadius.circular(16), onTap: () => Navigator.pop(sheetContext), child: Container( width: 32, height: 32, alignment: Alignment.center, decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xFFF1F5F9)), child: const Icon(Icons.close, size: 18, color: darkBg), ), ), ], ), 20.height, // Availability - single select, drives the real offerType filter. LocaleKeys.availability.tr().toText14(isBold: true, color: darkBg), 10.height, Row( children: [ buildOption(LocaleKeys.allCategories.tr(), draftAvailability == 'all', () => setSheetState(() => draftAvailability = 'all')), const SizedBox(width: 10), buildOption(LocaleKeys.online.tr(), draftAvailability == 'online', () => setSheetState(() => draftAvailability = 'online')), const SizedBox(width: 10), buildOption(LocaleKeys.inStore.tr(), draftAvailability == 'instore', () => setSheetState(() => draftAvailability = 'instore')), ], ), 20.height, // Sort by - single select, Nearest and Newest only. LocaleKeys.sortBy.tr().toText14(isBold: true, color: darkBg), 10.height, Row( children: [ buildOption(LocaleKeys.nearest.tr(), draftSortBy == 'nearest', () => setSheetState(() => draftSortBy = 'nearest'), icon: Icons.location_on_outlined), const SizedBox(width: 10), buildOption(LocaleKeys.newest.tr(), draftSortBy == 'newest', () => setSheetState(() => draftSortBy = 'newest')), ], ), 20.height, // Distance - single select. sectionHeader(LocaleKeys.distance.tr(), hint: LocaleKeys.fromYourCurrentLocation.tr()), 10.height, Row( children: [ buildOption(LocaleKeys.any.tr(), draftDistance == 'any', () => setSheetState(() => draftDistance = 'any')), const SizedBox(width: 8), buildOption(LocaleKeys.distanceKm.tr(args: ['5']), draftDistance == '5km', () => setSheetState(() => draftDistance = '5km')), const SizedBox(width: 8), buildOption(LocaleKeys.distanceKm.tr(args: ['10']), draftDistance == '10km', () => setSheetState(() => draftDistance = '10km')), const SizedBox(width: 8), buildOption(LocaleKeys.distanceKm.tr(args: ['25']), draftDistance == '25km', () => setSheetState(() => draftDistance = '25km')), ], ), 20.height, // Minimum discount - single select. LocaleKeys.minimumDiscount.tr().toText14(isBold: true, color: darkBg), 10.height, Row( children: [ buildOption(LocaleKeys.any.tr(), draftMinDiscount == 'any', () => setSheetState(() => draftMinDiscount = 'any')), const SizedBox(width: 8), buildOption(LocaleKeys.discountPercentPlus.tr(args: ['10']), draftMinDiscount == '10', () => setSheetState(() => draftMinDiscount = '10')), const SizedBox(width: 8), buildOption(LocaleKeys.discountPercentPlus.tr(args: ['20']), draftMinDiscount == '20', () => setSheetState(() => draftMinDiscount = '20')), const SizedBox(width: 8), buildOption(LocaleKeys.discountPercentPlus.tr(args: ['30']), draftMinDiscount == '30', () => setSheetState(() => draftMinDiscount = '30')), ], ), 20.height, const Divider(height: 1, thickness: 1, color: borderColor), 16.height, Row( children: [ Expanded( child: DefaultButton( LocaleKeys.reset.tr(), () { setSheetState(() { draftAvailability = 'all'; draftSortBy = 'newest'; draftDistance = 'any'; draftMinDiscount = 'any'; }); setState(() { selectedTypeFilter = draftAvailability; _filterSortBy = draftSortBy; _filterDistance = draftDistance; _filterMinDiscount = draftMinDiscount; }); Navigator.pop(sheetContext); fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId); }, colors: const [Colors.white, Colors.white], border: Border.all(color: borderColor), textColor: darkBg, ), ), const SizedBox(width: 12), Expanded( child: DefaultButton(LocaleKeys.applyFilters.tr(), () { setState(() { selectedTypeFilter = draftAvailability; _filterSortBy = draftSortBy; _filterDistance = draftDistance; _filterMinDiscount = draftMinDiscount; }); Navigator.pop(sheetContext); fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId); }), ), ], ), ], ), ), ); }, ); }, ); } Widget _buildCategoryCard(WalaPlusCategoryModel item, bool isSelected) { const double cardSize = 84.0; const double borderRadiusVal = 18.0; const double borderWidthVal = 2.8; const Color activeBorderColor = Color(0xFF00A86B); bool isAll = (item.id == "all" || item.id == "0" || (item.name ?? "").toLowerCase() == "all" || item.name == "الجميع" || item.name == "الكل"); Widget imageContent; if (isAll) { imageContent = Image.asset('assets/images/cat_all.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _getFallbackCategoryAsset(item)); } else { String categoryImageUrl = item.categoryImageUrl.isNotEmpty ? item.categoryImageUrl : (item.image?.trim() ?? ""); if (categoryImageUrl.isNotEmpty) { if (categoryImageUrl.toLowerCase().endsWith('.svg') || item.isSvg) { imageContent = SvgPicture.network( categoryImageUrl, width: double.infinity, height: double.infinity, fit: BoxFit.cover, placeholderBuilder: (_) => const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00A86B)))), ); } else { imageContent = Image.network(categoryImageUrl, width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _getFallbackCategoryAsset(item)); } } else { imageContent = _getFallbackCategoryAsset(item); } } return Container( width: cardSize, height: cardSize, decoration: BoxDecoration( color: Colors.transparent, borderRadius: BorderRadius.circular(borderRadiusVal), border: isSelected ? Border.all(color: activeBorderColor, width: borderWidthVal) : null, ), child: ClipRRect(borderRadius: BorderRadius.circular(isSelected ? (borderRadiusVal - borderWidthVal) : borderRadiusVal), child: imageContent), ); } Widget _getFallbackCategoryAsset(WalaPlusCategoryModel item) { String name = (item.name ?? "").toLowerCase(); if (item.id == "all" || item.id == "0" || name == "all" || name == "الكل" || name == "الجميع") { return Image.asset('assets/images/cat_all.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item)); } else if (name.contains("shop") || name.contains("retail") || name.contains("تسوق") || name.contains("تجزئة")) { return Image.asset('assets/images/cat_retail.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item)); } else if (name.contains("food") || name.contains("restaurant") || name.contains("مطاعم") || name.contains("أغذية") || name.contains("cafe") || name.contains("مقهى")) { return Image.asset('assets/images/cat_food.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item)); } else if (name.contains("fitness") || name.contains("wellness") || name.contains("sport") || name.contains("gym") || name.contains("رياضة") || name.contains("لياقة") || name.contains("صحة")) { return Image.asset('assets/images/cat_fitness.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item)); } return _buildFallbackCategoryIconWidget(item); } Widget _buildFallbackCategoryIconWidget(WalaPlusCategoryModel item) { return Container(color: Colors.transparent, alignment: Alignment.center, child: _getFallbackCategoryIcon(item, const Color(0xFF168A89))); } Widget _getFallbackCategoryIcon(WalaPlusCategoryModel item, Color iconColor) { String name = (item.name ?? "").toLowerCase(); IconData iconData = Icons.grid_view_rounded; Color color = iconColor; if (item.id == "all" || name == "all" || name == "الكل" || name == "الجميع") { iconData = Icons.confirmation_number_outlined; color = const Color(0xFFE52332); } else if (name.contains("finance") || name.contains("بنوك") || name.contains("مالي")) { iconData = Icons.account_balance_rounded; } else if (name.contains("entertain") || name.contains("activities") || name.contains("أنشطة") || name.contains("ترفيه")) { iconData = Icons.theater_comedy_rounded; } else if (name.contains("educat") || name.contains("تعليم")) { iconData = Icons.menu_book_rounded; } else if (name.contains("shop") || name.contains("retail") || name.contains("تسوق") || name.contains("تجزئة")) { iconData = Icons.shopping_bag_outlined; } else if (name.contains("health") || name.contains("medical") || name.contains("صحة") || name.contains("طبي")) { iconData = Icons.health_and_safety_outlined; } else if (name.contains("beauty") || name.contains("جمال") || name.contains("عناية")) { iconData = Icons.face_retouching_natural_rounded; } else if (name.contains("fitness") || name.contains("sport") || name.contains("gym") || name.contains("رياضة") || name.contains("لياقة")) { iconData = Icons.fitness_center_rounded; } else if (name.contains("hotel") || name.contains("travel") || name.contains("فندق") || name.contains("فنادق") || name.contains("سفر")) { iconData = Icons.hotel_rounded; } else if (name.contains("service") || name.contains("خدمات")) { iconData = Icons.build_circle_outlined; } else if (name.contains("food") || name.contains("restaurant") || name.contains("مطاعم") || name.contains("أغذية")) { iconData = Icons.restaurant_rounded; } else if (name.contains("home") || name.contains("منزل")) { iconData = Icons.home_rounded; } return Icon(iconData, color: color, size: 26); } String _getDiscountBadge(WalaPlusRetailerModel item) { if (item.offerTitle != null) { RegExp percentRegex = RegExp(r'(\d+%)'); Match? match = percentRegex.firstMatch(item.offerTitle!); if (match != null) { return match.group(1)!; } } if (item.name != null) { RegExp percentRegex = RegExp(r'(\d+%)'); Match? match = percentRegex.firstMatch(item.name!); if (match != null) { return match.group(1)!; } } return ""; } 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) { 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 offerSubtitle = ""; if (discountText.isNotEmpty) { if (item.offerTitle != null && item.offerTitle!.toLowerCase().contains("off")) { offerSubtitle = item.offerTitle!; } else { offerSubtitle = LocaleKeys.offDiscount.tr(args: [discountText]); } } else if (item.offerTitle != null && item.offerTitle!.trim().isNotEmpty) { 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) { usageText = item.displayUsage.replaceAll('usage:', LocaleKeys.usageLabel.tr()).replaceAll('Usage :', LocaleKeys.usageLabel.tr()); } String cityText = item.getDisplayCity(isAr: isAr); if (cityText.trim().isEmpty) { cityText = item.displayCity; } return InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _openRetailer(item), child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), 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( height: 120, width: double.infinity, child: Stack( fit: StackFit.expand, children: [ // Banner Image (bannerUrl != null && bannerUrl.isNotEmpty) ? Image.network( bannerUrl, fit: BoxFit.cover, 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, right: 0, height: 50, child: Container( 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, end: 0, child: Container( padding: const EdgeInsetsDirectional.only(start: 14, end: 10, top: 4, bottom: 4), decoration: const BoxDecoration(color: Color(0xFFED1C24), borderRadius: BorderRadiusDirectional.only(topStart: Radius.circular(20), bottomStart: Radius.circular(20))), child: discountText.toText14(isBold: true, color: Colors.white), ), ), // Brand logo & name at bottom left PositionedDirectional( bottom: 8, start: 8, end: 8, child: Row( children: [ Container( width: 18, 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(), ), const SizedBox(width: 5), Expanded(child: (item.name ?? "").toText11(isBold: true, maxLine: 1, color: Colors.white)), ], ), ), ], ), ), ), 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, child: ListView( 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), 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), horizontalPadding: 10, verticalPadding: 5, borderRadius: 12, iconSize: 14, ), const SizedBox(width: 5), ], // Location Badge if (cityText.trim().isNotEmpty) ...[ InfoChipWidget( text: cityText, icon: Icons.location_on_outlined, backgroundColor: const Color(0xFFF8FAFC), textColor: const Color(0xFF475569), borderColor: const Color(0xFFCBD5E1), horizontalPadding: 10, verticalPadding: 5, borderRadius: 12, iconSize: 14, ), 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), ], ), ), ], ), ), ), ); } 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))), ], ), ], ), ); }, ), ); } 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))), ], ), ); }, ), ), ); } }