import 'dart:async'; import 'dart:convert'; 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/models/generic_response_model.dart'; import 'package:mohem_flutter_app/models/offers_and_discounts/get_offers_list.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_city_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/ui/mazaya/widgets/mazaya_city_selector.dart'; import 'package:mohem_flutter_app/ui/mazaya/widgets/wala_plus_retailer_grid.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:permission_handler/permission_handler.dart'; import 'package:shimmer/shimmer.dart'; class RetailersListScreen extends StatefulWidget { const RetailersListScreen({Key? key}) : super(key: key); @override State createState() => _RetailersListScreenState(); } class _RetailersListScreenState extends State { List retailersList = []; List _filteredRetailersList = []; List categoriesList = []; WalaPlusCategoryModel? selectedCategory; String currentCategoryId = ""; String selectedTypeFilter = "all"; WalaPlusCityModel? selectedCity; bool _isInitRouteArgs = false; // Reached from Discover's "Exclusive offers" See all - reuses the legacy Offers & Discounts // (ITG) API/implementation (lib/ui/screens/offers_and_discounts) instead of a WalaPlus // endpoint, so fetchCategories/fetchOffers/fetchSubcategories all short-circuit to // fetchExclusiveOffers below rather than touching any WalaPlus-specific state or UI. bool isExclusiveMode = false; List _exclusiveOffers = []; // Reached by tapping Discover's search field - same "all categories / newest" data as the // "Latest on Mazaya" See all, but the category hero is hidden (there's no specific category // context, it's a pure search entry point) and the search bar auto-focuses with the keyboard // open instead. bool isSearchMode = false; final FocusNode _searchFocusNode = FocusNode(); // 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 = 'newest'; String _filterDistance = 'any'; String _filterMinDiscount = 'any'; bool isNearestOffers = true; 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; @override void initState() { super.initState(); _scrollController.addListener(_scrollListener); _loadDependentName(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (mounted) { await _saveDependentSessionToPrefs(); await _registerMobileLoginInfo(); await fetchCategories(); } }); } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_isInitRouteArgs) { _isInitRouteArgs = true; Object? args = ModalRoute.of(context)?.settings.arguments; if (args is Map && args['exclusiveMode'] == true) { isExclusiveMode = true; } else if (args is WalaPlusCategoryModel) { selectedCategory = args; currentCategoryId = args.id ?? ""; } else if (args is Map && args['category'] is WalaPlusCategoryModel) { selectedCategory = args['category'] as WalaPlusCategoryModel; currentCategoryId = selectedCategory?.id ?? ""; } if (args is Map && args['searchMode'] == true) { isSearchMode = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _searchFocusNode.requestFocus(); }); } // Preserves whichever city was already selected on Discover - starts here instead of // resetting back to "All Cities" until the user explicitly changes it on this page. if (args is Map && args['city'] is WalaPlusCityModel) { selectedCity = args['city'] as WalaPlusCityModel; } else if (AppState().selectedMazayaCity != null) { selectedCity = AppState().selectedMazayaCity; } } } 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 != null && AppState().getDeviceToken!.isNotEmpty && AppState().getDeviceToken != "null") ? AppState().getDeviceToken! : ""); if (deviceToken.isEmpty || deviceToken == "null") { String savedToken = await Utils.getStringFromPrefs( "P_DEP_DEVICE_TOKEN", ); if (savedToken.isNotEmpty && savedToken != "null") { deviceToken = savedToken; } } 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(); } @override void dispose() { _debounceTimer?.cancel(); _scrollController.removeListener(_scrollListener); _scrollController.dispose(); _searchController.dispose(); _searchFocusNode.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(); } } } // Reuses the legacy Offers & Discounts (ITG) API/implementation // (lib/ui/screens/offers_and_discounts) rather than a WalaPlus endpoint - categoryID 0 is the // same "all categories" value that screen's own initial load uses. There's no query param on // getOffersList, so search filters the already-fetched list client-side instead of re-fetching. // Each result is mapped onto WalaPlusRetailerModel (see fromExclusiveOffer) so it renders // through the exact same grid/card used everywhere else, flagged via isExclusive. Future fetchExclusiveOffers({ String? query, bool forceRefresh = false, }) async { if (query != null) searchQuery = query; if (forceRefresh) _exclusiveOffers = []; setState(() { isLoading = true; hasMore = false; }); try { if (_exclusiveOffers.isEmpty) { bool isAr = AppState().isArabic(context); List offers = await OffersAndDiscountsApiClient() .getOffersList(0, 100); _exclusiveOffers = offers .map( (o) => WalaPlusRetailerModel.fromExclusiveOffer(o, isAr: isAr), ) .toList(); } retailersList = _exclusiveOffers; String q = searchQuery.trim().toLowerCase(); _filteredRetailersList = q.isEmpty ? _exclusiveOffers : _exclusiveOffers .where((o) => (o.name ?? '').toLowerCase().contains(q)) .toList(); } catch (e) { debugPrint("RetailersListScreen: error fetching exclusive offers: $e"); _filteredRetailersList = []; } finally { if (mounted) { setState(() { isLoading = false; isCategoriesLoading = false; }); } } } Future fetchCategories() async { if (isExclusiveMode) { await fetchExclusiveOffers(); return; } 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) { categoriesList = list; if (selectedCategory != null) { for (WalaPlusCategoryModel cat in list) { if (cat.id == selectedCategory!.id) { selectedCategory = cat; break; } } } else { WalaPlusCategoryModel? defaultCat; for (WalaPlusCategoryModel cat in list) { String n = (cat.name ?? "").toLowerCase(); if (n.contains("travel") || n.contains("entertain") || n.contains("سفر") || n.contains("ترفيه")) { defaultCat = cat; break; } } defaultCat ??= list.firstWhere( (c) => c.id != "all" && c.id != "0" && (c.name ?? "").toLowerCase() != "all" && c.name != "الكل" && c.name != "الجميع", orElse: () => list.isNotEmpty ? list.first : WalaPlusCategoryModel( id: "1", name: "Travel & Entertainment", ), ); selectedCategory = defaultCat; } currentCategoryId = selectedCategory?.id ?? ""; setState(() {}); if (currentCategoryId.isNotEmpty) { await fetchSubcategories(currentCategoryId); await fetchOffers(categoryId: currentCategoryId); } } } 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) { categoriesList = retryList; if (selectedCategory != null) { for (WalaPlusCategoryModel cat in retryList) { if (cat.id == selectedCategory!.id) { selectedCategory = cat; break; } } } else { WalaPlusCategoryModel? defaultCat; for (WalaPlusCategoryModel cat in retryList) { String n = (cat.name ?? "").toLowerCase(); if (n.contains("travel") || n.contains("entertain") || n.contains("سفر") || n.contains("ترفيه")) { defaultCat = cat; break; } } defaultCat ??= retryList.firstWhere( (c) => c.id != "all" && c.id != "0" && (c.name ?? "").toLowerCase() != "all" && c.name != "الكل" && c.name != "الجميع", orElse: () => retryList.first, ); selectedCategory = defaultCat; } currentCategoryId = selectedCategory?.id ?? ""; setState(() {}); if (currentCategoryId.isNotEmpty) { await fetchSubcategories(currentCategoryId); await fetchOffers(categoryId: currentCategoryId); } return; } } catch (retryEx) { debugPrint("Error fetching WalaPlus categories (attempt 2): $retryEx"); } if (mounted) { if (selectedCategory == null) { selectedCategory = WalaPlusCategoryModel( id: "1", name: "Travel & Entertainment", ); currentCategoryId = "1"; } setState(() {}); await fetchOffers(categoryId: currentCategoryId); } } 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 (isExclusiveMode) return; 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; }); } } } 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 fetchOffers({String? categoryId, String? query}) async { if (isExclusiveMode) { await fetchExclusiveOffers(query: query); return; } String targetCategoryId = categoryId ?? _selectedSubcategoryId ?? (currentCategoryId.isEmpty ? (selectedCategory?.id ?? "") : 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 = (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); 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: _filterSortBy, radius: radiusParam, minDiscount: minDiscountParam, cityId: (selectedCity != null && !selectedCity!.isAllCities) ? selectedCity!.cityId : null, context: wantsNearest ? context : null, userReference: null, ); retailersList = response?.retailers ?? []; _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 effectiveCategoryId = _selectedSubcategoryId ?? currentCategoryId; String catParam = (effectiveCategoryId == "all" || effectiveCategoryId == "0") ? "" : effectiveCategoryId; 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); print( "Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam'", ); WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient() .getWalaPlusRetailers( categoryId: catParam, page: nextPage, perPage: pageSize, offerType: offerTypeParam, query: queryParam, sortBy: _filterSortBy, radius: radiusParam, minDiscount: minDiscountParam, cityId: (selectedCity != null && !selectedCity!.isAllCities) ? selectedCity!.cityId : null, context: wantsNearest ? context : null, userReference: null, ); List newRetailers = response?.retailers ?? []; if (newRetailers.isNotEmpty) { 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 { if (isExclusiveMode) { await fetchExclusiveOffers( query: _searchController.text, forceRefresh: true, ); return; } String effectiveCategoryId = _selectedSubcategoryId ?? (currentCategoryId.isEmpty ? (selectedCategory?.id ?? "") : currentCategoryId); await Future.wait(>[ fetchCategories(), fetchOffers( categoryId: effectiveCategoryId, 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) { Utils.showToast( 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; String effectiveCategoryId = _selectedSubcategoryId ?? currentCategoryId; if (query.trim().isEmpty) { fetchOffers(categoryId: effectiveCategoryId, query: ""); } else { _debounceTimer = Timer(const Duration(milliseconds: 600), () { fetchOffers(categoryId: effectiveCategoryId, query: query.trim()); }); } } Future _toggleFavorite(WalaPlusRetailerModel item) async { String retailerId = item.id ?? ""; if (retailerId.isEmpty) return; bool currentFav = item.isFavorite ?? false; bool targetFav = !currentFav; setState(() { item.isFavorite = targetFav; }); Utils.showLoading(context); try { GenericResponseModel? response; String? userRef = _getUserReferenceForFavorites(); if (targetFav) { response = await OffersAndDiscountsApiClient().addWalaPlusFavorite( retailerId: retailerId, context: context, userReference: userRef, ); } else { response = await OffersAndDiscountsApiClient().removeWalaPlusFavorite( retailerId: retailerId, context: context, userReference: userRef, ); } Utils.hideLoading(context); if (response != null && response.errorMessage == null) { bool isAr = AppState().isArabic(context); String msg; if (isAr) { msg = targetFav ? LocaleKeys.favoriteAddedSuccess.tr() : LocaleKeys.favoriteRemovedSuccess.tr(); } else { msg = response.wpFavoritesAddRemoveMessage ?? ""; if (msg.trim().isEmpty) { msg = targetFav ? LocaleKeys.favoriteAddedSuccess.tr() : LocaleKeys.favoriteRemovedSuccess.tr(); } } Utils.showToast(msg, isSuccess: true); } else { if (mounted) { setState(() { item.isFavorite = currentFav; }); } String error = response?.errorEndUserMessage ?? response?.errorMessage ?? LocaleKeys.favoriteError.tr(); Utils.showToast(error); } } catch (e) { Utils.hideLoading(context); debugPrint("Error toggling favorite: $e"); if (mounted) { setState(() { item.isFavorite = currentFav; }); } Utils.showToast(LocaleKeys.favoriteError.tr()); } } void _openRetailer(WalaPlusRetailerModel item) async { if (item.categories == null || item.categories!.isEmpty) { if (selectedCategory != null) { item.categories = [selectedCategory!]; } else if (currentCategoryId.isNotEmpty && currentCategoryId != "all") { for (var c in categoriesList) { if (c.id?.toString() == currentCategoryId) { item.categories = [c]; break; } } } } await Navigator.pushNamed( context, AppRoutes.retailerDetailsScreen, arguments: item, ); if (mounted) { 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( children: [ InkWell( borderRadius: BorderRadius.circular(10), onTap: () { if (Navigator.canPop(context)) { Navigator.pop(context); } else if (ApiConsts.isDependetLogin) { _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), LocaleKeys.mazayaDeals.tr().toText22( isBold: true, color: const Color(0xFF202F31), ), ], ), ), actions: [ if (ApiConsts.isDependetLogin) ...[ _buildLanguageIconButton(isAr), const SizedBox(width: 4), InkWell( borderRadius: BorderRadius.circular(8), onTap: _performLogout, child: Padding( padding: const EdgeInsets.all(6), child: SvgPicture.asset( 'assets/images/drawer/logout.svg', height: 20, width: 20, ), ), ), const SizedBox(width: 12), ], ], ), 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: [ // 1. Sub-header: "Explore your benefits" | "📍 Riyadh" _buildSubHeaderRow(isAr), // 2. Category Hero Section: Title, Subtitle, Category image - hidden when // reached from Discover's search field, since there's no specific category // context there and the search bar should be the first thing on screen. if (!isSearchMode) _buildCategoryHero(isAr), // 3. Search Bar: "Find an escape or an experience" _buildSearchBar(isAr), // 4. Filters & Subcategories Row - the filters (sort/distance/discount) and // subcategories are WalaPlus-specific concepts that don't apply to exclusive // offers (legacy Offers & Discounts API), so this row is skipped in that mode. if (!isExclusiveMode) _buildSubcategoriesAndFilterRow(isAr), const SizedBox(height: 16), // 5. Offers Grid WalaPlusRetailerGrid( isLoading: isLoading, retailers: _filteredRetailersList, isAr: isAr, isLoadingMore: isLoadingMore, onTapRetailer: _openRetailer, onFavoriteTap: _toggleFavorite, mainAxisExtent: isExclusiveMode ? 190 : null, ), ], ), ), ), ), ); } Widget _buildSubHeaderRow(bool isAr) { return Padding( padding: const EdgeInsets.only(top: 4, bottom: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ LocaleKeys.exploreYourBenefits.tr().toText13( color: const Color(0xFF79807F), ), InkWell( borderRadius: BorderRadius.circular(8), onTap: _showCitySelectionBottomSheet, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.location_on_outlined, size: 16, color: Color(0xFF157D6B), ), const SizedBox(width: 4), (selectedCity?.getDisplayName(isAr) ?? LocaleKeys.allCitiesOption.tr()) .toText13(isBold: true, color: const Color(0xFF157D6B)), ], ), ), ), ], ), ); } Widget _buildCategoryHero(bool isAr) { bool isLatest = selectedCategory?.id == 'all'; String categoryName = isExclusiveMode ? LocaleKeys.exclusivelyYours.tr() : isLatest ? LocaleKeys.latestOnMazaya.tr() : selectedCategory?.name ?? (isAr ? "السفر والترفيه" : "Travel & Entertainment"); String subtitle = isExclusiveMode ? LocaleKeys.aLittleExtraJustForMazayaMembers.tr() : isLatest ? LocaleKeys.goodFoodGreatCompanyEvenBetterPerks.tr() : (isAr ? "مغامرتك القادمة، مع القليل من التميز." : "Your next adventure, with a little extra."); return Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ categoryName.toText24( isBold: true, color: const Color(0xFF202F31), ), const SizedBox(height: 6), subtitle.toText12(color: const Color(0xFF7A8180)), ], ), ), const SizedBox(width: 16), _buildCategoryHeroImage( selectedCategory, isLatest: isLatest, isExclusive: isExclusiveMode, ), ], ), ); } Widget _buildCategoryHeroImage( WalaPlusCategoryModel? category, { bool isLatest = false, bool isExclusive = false, }) { const double imgWidth = 84.0; const double imgHeight = 84.0; const double radius = 18.0; if (isExclusive) { return _buildCategoryHeroFallback( category, imgWidth, imgHeight, radius, isExclusive: true, ); } if (isLatest) { return _buildCategoryHeroFallback( category, imgWidth, imgHeight, radius, isLatest: true, ); } String? imgUrl = category?.categoryImageUrl; if (imgUrl != null && imgUrl.isNotEmpty) { if (imgUrl.toLowerCase().endsWith('.svg') || (category?.isSvg ?? false)) { return ClipRRect( borderRadius: BorderRadius.circular(radius), child: SizedBox( width: imgWidth, height: imgHeight, child: SvgPicture.network( imgUrl, fit: BoxFit.cover, placeholderBuilder: (_) => _buildCategoryHeroFallback( category, imgWidth, imgHeight, radius, ), ), ), ); } return ClipRRect( borderRadius: BorderRadius.circular(radius), child: SizedBox( width: imgWidth, height: imgHeight, child: Image.network( imgUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildCategoryHeroFallback( category, imgWidth, imgHeight, radius, ), ), ), ); } return _buildCategoryHeroFallback(category, imgWidth, imgHeight, radius); } Widget _buildCategoryHeroFallback( WalaPlusCategoryModel? category, double w, double h, double r, { bool isLatest = false, bool isExclusive = false, }) { return ClipRRect( borderRadius: BorderRadius.circular(r), child: Container( width: w, height: h, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: isExclusive ? const [Color(0xFF143F36), Color(0xFF0B2A23)] : isLatest ? const [Color(0xFF1FA98D), Color(0xFF147D6B)] : const [Color(0xFF38BDF8), Color(0xFF0284C7)], ), ), child: Center( child: Icon( isExclusive ? Icons.workspace_premium_rounded : isLatest ? Icons.local_offer_rounded : Icons.flight_takeoff_rounded, size: 36, color: isExclusive ? const Color(0xFFDCC58F) : Colors.white, ), ), ), ); } Widget _buildSearchBar(bool isAr) { return Container( width: double.infinity, height: 46, padding: const EdgeInsets.symmetric(horizontal: 14), alignment: Alignment.center, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE8EAE6), width: 1.0), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ const Icon(Icons.search_rounded, size: 20, color: Color(0xFF79807F)), const SizedBox(width: 10), Expanded( child: TextField( controller: _searchController, focusNode: _searchFocusNode, onChanged: _onSearchChanged, onSubmitted: (String val) { _debounceTimer?.cancel(); fetchOffers(query: val.trim()); }, textInputAction: TextInputAction.search, textAlignVertical: TextAlignVertical.center, style: const TextStyle( color: Color(0xFF202F31), fontSize: 13, fontWeight: FontWeight.w400, ), decoration: InputDecoration( isDense: true, contentPadding: EdgeInsets.zero, hintText: LocaleKeys.findAnEscapeOrAnExperience.tr(), hintStyle: const TextStyle( color: Color(0xFF7A8180), fontSize: 13, fontWeight: FontWeight.w400, ), border: InputBorder.none, ), ), ), ValueListenableBuilder( valueListenable: _searchController, builder: ( BuildContext context, TextEditingValue value, Widget? child, ) { if (value.text.isEmpty) return const SizedBox.shrink(); return InkWell( borderRadius: BorderRadius.circular(16), onTap: () { _searchController.clear(); _onSearchChanged(""); }, child: const Padding( padding: EdgeInsets.all(4), child: Icon(Icons.clear, size: 18, color: Color(0xFF848486)), ), ); }, ), ], ), ); } int _activeFilterCount() { int count = 0; if (selectedTypeFilter != 'all') count++; if (_filterSortBy != 'newest') count++; if (_filterDistance != 'any') count++; if (_filterMinDiscount != 'any') count++; return count; } Widget _buildSubcategoriesAndFilterRow(bool isAr) { bool showSubcategoriesRow = currentCategoryId.isNotEmpty && currentCategoryId != "all" && (isSubcategoriesLoading || subcategoriesList.isNotEmpty); int activeFilterCount = _activeFilterCount(); return Padding( padding: const EdgeInsets.only(top: 14, bottom: 2), child: SizedBox( height: 38, child: Row( children: [ Stack( clipBehavior: Clip.none, 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 (activeFilterCount > 0) PositionedDirectional( top: -5, end: -5, child: Container( width: 18, height: 18, alignment: Alignment.center, decoration: const BoxDecoration( color: MyColors.darkIconColor, shape: BoxShape.circle, ), child: '$activeFilterCount'.toText10( isBold: true, color: Colors.white, ), ), ), ], ), 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(), 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); }); 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, ), ), ); }, ), ), ], ], ), ), ); } void _showCitySelectionBottomSheet() { showMazayaCitySelectionBottomSheet( context, selectedCityId: selectedCity?.cityId, onCitySelected: (city) { WalaPlusCityModel? newCity = city.isAllCities ? null : city; AppState().setSelectedMazayaCity = newCity; if (newCity != null) { Utils.saveStringFromPrefs( "MAZAYA_SELECTED_CITY", jsonEncode(newCity.toJson()), ); } else { Utils.removeStringFromPrefs("MAZAYA_SELECTED_CITY"); } setState(() => selectedCity = newCity); String effectiveCategoryId = _selectedSubcategoryId ?? (currentCategoryId.isEmpty ? (selectedCategory?.id ?? "") : currentCategoryId); fetchOffers( categoryId: effectiveCategoryId, query: _searchController.text, ); }, ); } // 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 ? MyColors.lightGreyEAColor : Colors.white, border: Border.all( color: isSelected ? MyColors.lightGreyEAColor : borderColor, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon( icon, size: 14, color: isSelected ? MyColors.black : const Color(0xFF64748B), ), const SizedBox(width: 5), ], Flexible( child: label.toText13( isBold: true, color: isSelected ? MyColors.black : darkBg, maxlines: 1, ), ), if (isSelected) ...[ const SizedBox(width: 5), const Icon( Icons.check, size: 14, color: MyColors.black, ), ], ], ), ), ), ); } 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: _selectedSubcategoryId ?? currentCategoryId, ); }, ), ), ], ), ], ), ), ); }, ); }, ); } void showFavoritesBottomSheet() async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, elevation: 0, builder: (BuildContext sheetContext) { return FavoriteMerchantsBottomSheet(categoriesList: categoriesList); }, ); if (mounted) { setState(() {}); } } } class WalaPlusRetailerCard extends StatelessWidget { final WalaPlusRetailerModel item; final bool isAr; final VoidCallback onTap; final VoidCallback? onFavoriteTap; const WalaPlusRetailerCard({ Key? key, required this.item, required this.isAr, required this.onTap, this.onFavoriteTap, }) : super(key: key); static 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 ""; } 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; } @override Widget build(BuildContext context) { String? bannerUrl = (item.imageThumb != null && item.imageThumb!.isNotEmpty) ? item.imageThumb : item.image; 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(); } 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; } bool hasChips = offerTypes.isNotEmpty || cityText.trim().isNotEmpty || usageText.trim().isNotEmpty; return InkWell( borderRadius: BorderRadius.circular(18), onTap: onTap, child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.08), blurRadius: 24, spreadRadius: 0, ), ], ), padding: const EdgeInsets.all(10), child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Figma redesign (node 927:496): shorter image, no bottom gradient/logo overlay // (brand moved into the card body below), no discount ribbon - the percentage now // lives inline in the teal offer subtitle text instead. Image height trimmed from // 120->110 (not Figma's 82) and padding/gaps left untouched on purpose: this card's // overall size is mainAxisExtent-driven (see the grid's gridDelegate) and was // already tuned against real devices - copying Figma's absolute pixels here would // reintroduce the overflow/empty-space problems that tuning fixed. ClipRRect( borderRadius: BorderRadius.circular(14), child: SizedBox( height: 110, width: double.infinity, child: Stack( fit: StackFit.expand, children: [ (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, ), ), // Exclusive offers (mapped from the legacy Offers & Discounts API) have no // real WalaPlus retailer id, so there's nothing for add/removeWalaPlusFavorite // to act on - hide the heart rather than wiring it to a call that can't work. if (item.isExclusive != true) PositionedDirectional( top: 7, end: 7, child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onFavoriteTap, child: Container( width: 27, height: 27, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.12), blurRadius: 5, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: Icon( item.isFavorite == true ? Icons.favorite_rounded : Icons.favorite_border_rounded, size: 15, color: item.isFavorite == true ? Colors.black : const Color(0xFF1E293B), ), ), ), ), if (item.isExclusive == true) PositionedDirectional( bottom: 7, start: 8, child: Container( padding: const EdgeInsets.symmetric( horizontal: 7, vertical: 4, ), decoration: BoxDecoration( color: const Color(0xFF143F36), borderRadius: BorderRadius.circular(6), ), child: LocaleKeys.mazayaExclusive.tr().toText10( color: const Color(0xFFF1EBDD), ), ), ), ], ), ), ), const SizedBox(height: 8), // Brand name + "view details" affordance arrow, replacing the old image-overlay name. Row( children: [ Expanded( child: (item.name ?? "").toText13( isBold: true, maxlines: 1, overflow: TextOverflow.ellipsis, color: const Color(0xFF202F32), ), ), const SizedBox(width: 4), const Icon( Icons.north_east_rounded, size: 16, color: Color(0xFF147D6B), ), ], ), const SizedBox(height: 2), offerSubtitle.toText14( maxlines: 1, overflow: TextOverflow.ellipsis, isBold: true, color: const Color(0xFF147D6B), ), if (hasChips) ...[ const SizedBox(height: 6), // Fully-rounded pill badges (no icon, no border) matching the new design - type // chips in blue (In-store) / green (Online), city in grey. SizedBox( height: 28, child: ListView( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), children: [ for (String offerType in offerTypes) ...[ InfoChipWidget( text: getTypeFilterTitle(offerType), backgroundColor: offerType == 'online' ? const Color(0xFFEAF3EE) : const Color(0x14062CFF), textColor: offerType == 'online' ? const Color(0xFF147D6B) : const Color(0xFF2A56B6), borderColor: Colors.transparent, borderWidth: 0, isBold: false, horizontalPadding: 10, verticalPadding: 6, borderRadius: 99, ), const SizedBox(width: 5), ], if (cityText.trim().isNotEmpty) ...[ InfoChipWidget( text: cityText, backgroundColor: const Color(0xFFEFF2EF), textColor: const Color(0xFF464746), borderColor: Colors.transparent, borderWidth: 0, isBold: false, horizontalPadding: 10, verticalPadding: 6, borderRadius: 99, ), const SizedBox(width: 5), ], if (usageText.trim().isNotEmpty) InfoChipWidget( text: usageText, backgroundColor: const Color(0xFFFEF3C7), textColor: const Color(0xFFB45309), borderColor: Colors.transparent, borderWidth: 0, isBold: false, horizontalPadding: 10, verticalPadding: 6, borderRadius: 99, ), ], ), ), ], ], ), ), ), ); } } class FavoriteMerchantsBottomSheet extends StatefulWidget { final List? categoriesList; const FavoriteMerchantsBottomSheet({Key? key, this.categoriesList}) : 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(); } }); } @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()); }); } } Future _toggleFavorite(WalaPlusRetailerModel item) async { String retailerId = item.id ?? ""; if (retailerId.isEmpty) return; bool currentFav = item.isFavorite ?? true; bool targetFav = !currentFav; setState(() { item.isFavorite = targetFav; if (!targetFav) { favoriteRetailers.removeWhere((r) => r.id == item.id); } }); Utils.showLoading(context); try { GenericResponseModel? response; String? userRef = _getUserReferenceForFavorites(); if (targetFav) { response = await OffersAndDiscountsApiClient().addWalaPlusFavorite( retailerId: retailerId, context: context, userReference: userRef, ); } else { response = await OffersAndDiscountsApiClient().removeWalaPlusFavorite( retailerId: retailerId, context: context, userReference: userRef, ); } Utils.hideLoading(context); if (response != null && response.errorMessage == null) { bool isAr = AppState().isArabic(context); String msg = isAr ? (targetFav ? LocaleKeys.favoriteAddedSuccess.tr() : LocaleKeys.favoriteRemovedSuccess.tr()) : (response.wpFavoritesAddRemoveMessage ?? (targetFav ? LocaleKeys.favoriteAddedSuccess.tr() : LocaleKeys.favoriteRemovedSuccess.tr())); Utils.showToast(msg, isSuccess: true); } else { Utils.showToast( response?.errorEndUserMessage ?? response?.errorMessage ?? LocaleKeys.favoriteError.tr(), ); await fetchFavorites(query: _searchController.text); } } catch (e) { Utils.hideLoading(context); debugPrint("Error toggling favorite in bottom sheet: $e"); Utils.showToast(LocaleKeys.favoriteError.tr()); await fetchFavorites(query: _searchController.text); } } void _openRetailer(WalaPlusRetailerModel item) async { if ((item.categories == null || item.categories!.isEmpty) && widget.categoriesList != null && item.categoryId != null) { for (var c in widget.categoriesList!) { if (c.id?.toString() == item.categoryId?.toString()) { item.categories = [c]; break; } } } await Navigator.pushNamed( context, AppRoutes.retailerDetailsScreen, 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.85, 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: [ LocaleKeys.favoriteOffers.tr().toText18( weight: FontWeight.w600, color: const Color(0xFF1E293B), ), ], ), Row( mainAxisSize: MainAxisSize.min, children: [ 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: SingleChildScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: Column( children: [ WalaPlusRetailerGrid( isLoading: isLoading, retailers: favoriteRetailers, isAr: isAr, isLoadingMore: isLoadingMore, onTapRetailer: _openRetailer, onFavoriteTap: _toggleFavorite, ), const SizedBox(height: 20), ], ), ), ), ), ], ), ), ), ); } }