import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; import 'package:mohem_flutter_app/generated/locale_keys.g.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'; class WalaPlusRetailersResponse { int? page; int? pagesCount; int? perPage; List? retailers; String? countryNameAr; String? countryNameEn; WalaPlusRetailersResponse({this.page, this.pagesCount, this.perPage, this.retailers, this.countryNameAr, this.countryNameEn}); factory WalaPlusRetailersResponse.fromJson(Map json) { List retailersList = []; dynamic retailersData = json['retailers'] ?? json['Retailers'] ?? json['data'] ?? json['items']; if (retailersData is String) { try { retailersData = jsonDecode(retailersData); } catch (_) {} } if (retailersData is List) { retailersData.forEach((v) { if (v is Map) { retailersList.add(WalaPlusRetailerModel.fromJson(v)); } else if (v is Map) { retailersList.add(WalaPlusRetailerModel.fromJson(Map.from(v))); } }); } return WalaPlusRetailersResponse( page: json['page'] is int ? json['page'] : int.tryParse(json['page']?.toString() ?? json['WP_Page']?.toString() ?? '1'), pagesCount: json['pages_count'] is int ? json['pages_count'] : int.tryParse(json['pages_count']?.toString() ?? json['pagesCount']?.toString() ?? json['totalPages']?.toString() ?? '1'), perPage: json['per_page'] is int ? json['per_page'] : int.tryParse(json['per_page']?.toString() ?? json['perPage']?.toString() ?? json['WP_Per_page']?.toString() ?? '10'), retailers: retailersList, countryNameAr: WalaPlusRetailerModel.decodeUnicodeString(json['country_name_ar'] ?? json['countryNameAr'] ?? json['country_ar']), countryNameEn: WalaPlusRetailerModel.decodeUnicodeString(json['country_name_en'] ?? json['countryNameEn'] ?? json['country_en']), ); } Map toJson() { Map data = {}; data['page'] = page; data['pages_count'] = pagesCount; data['per_page'] = perPage; data['country_name_ar'] = countryNameAr; data['country_name_en'] = countryNameEn; if (retailers != null) { data['retailers'] = retailers!.map((v) => v.toJson()).toList(); } return data; } } class WalaPlusRetailersByIDResponse { int? page; int? pagesCount; int? perPage; List? locations; List? offers; List? retailer; WalaPlusRetailersByIDResponse({this.page, this.pagesCount, this.perPage, this.locations, this.offers, this.retailer}); factory WalaPlusRetailersByIDResponse.fromJson(Map json) { List locList = WalaPlusRetailerModel.parseLocationsList(json['locations'] ?? json['Locations'] ?? json['location'] ?? json['Location']); List offList = WalaPlusRetailerModel.parseOffersList(json['offers'] ?? json['Offers']); List catList = []; dynamic catData = json['categories'] ?? json['Categories'] ?? json['category'] ?? json['Category']; if (catData is List) { for (var c in catData) { if (c is Map) { catList.add(WalaPlusCategoryModel.fromJson(c)); } else if (c is Map) { catList.add(WalaPlusCategoryModel.fromJson(Map.from(c))); } else if (c is String && c.trim().isNotEmpty) { catList.add(WalaPlusCategoryModel(name: c.trim())); } } } else if (catData is Map) { catList.add(WalaPlusCategoryModel.fromJson(Map.from(catData))); } else if (catData is String && catData.trim().isNotEmpty) { catList.add(WalaPlusCategoryModel(name: catData.trim())); } List retList = []; dynamic retData = json['retailer'] ?? json['Retailer']; if (retData is List) { for (var r in retData) { if (r is Map) { var model = WalaPlusRetailerModel.fromJson(r); if (model.locations == null || model.locations!.isEmpty) model.locations = locList; if (model.offers == null || model.offers!.isEmpty) model.offers = offList; if ((model.categories == null || model.categories!.isEmpty) && catList.isNotEmpty) model.categories = catList; if ((model.website == null || model.website!.isEmpty || model.website == "null") && (json['website'] != null || json['merchant_website'] != null || json['url'] != null)) { model.website = (json['website'] ?? json['merchant_website'] ?? json['url'])?.toString(); } if (model.isFavorite == null && (json.containsKey('isfavorite') || json.containsKey('is_favorite'))) { var favVal = json['isfavorite'] ?? json['is_favorite']; model.isFavorite = favVal == true || favVal == 1 || favVal == 'true' || favVal == '1'; } retList.add(model); } else if (r is Map) { var model = WalaPlusRetailerModel.fromJson(Map.from(r)); if (model.locations == null || model.locations!.isEmpty) model.locations = locList; if (model.offers == null || model.offers!.isEmpty) model.offers = offList; if ((model.categories == null || model.categories!.isEmpty) && catList.isNotEmpty) model.categories = catList; if ((model.website == null || model.website!.isEmpty || model.website == "null") && (json['website'] != null || json['merchant_website'] != null || json['url'] != null)) { model.website = (json['website'] ?? json['merchant_website'] ?? json['url'])?.toString(); } if (model.isFavorite == null && (json.containsKey('isfavorite') || json.containsKey('is_favorite'))) { var favVal = json['isfavorite'] ?? json['is_favorite']; model.isFavorite = favVal == true || favVal == 1 || favVal == 'true' || favVal == '1'; } retList.add(model); } } } else if (retData is Map) { var model = WalaPlusRetailerModel.fromJson(Map.from(retData)); if (model.locations == null || model.locations!.isEmpty) model.locations = locList; if (model.offers == null || model.offers!.isEmpty) model.offers = offList; if ((model.categories == null || model.categories!.isEmpty) && catList.isNotEmpty) model.categories = catList; if ((model.website == null || model.website!.isEmpty || model.website == "null") && (json['website'] != null || json['merchant_website'] != null || json['url'] != null)) { model.website = (json['website'] ?? json['merchant_website'] ?? json['url'])?.toString(); } if (model.isFavorite == null && (json.containsKey('isfavorite') || json.containsKey('is_favorite'))) { var favVal = json['isfavorite'] ?? json['is_favorite']; model.isFavorite = favVal == true || favVal == 1 || favVal == 'true' || favVal == '1'; } retList.add(model); } return WalaPlusRetailersByIDResponse( page: json['page'] is int ? json['page'] : int.tryParse(json['page']?.toString() ?? json['WP_Page']?.toString() ?? ''), pagesCount: json['pages_count'] is int ? json['pages_count'] : int.tryParse(json['pages_count']?.toString() ?? json['pagesCount']?.toString() ?? ''), perPage: json['per_page'] is int ? json['per_page'] : int.tryParse(json['per_page']?.toString() ?? json['perPage']?.toString() ?? ''), locations: locList, offers: offList, retailer: retList, ); } WalaPlusRetailerModel? get primaryRetailer { if (retailer != null && retailer!.isNotEmpty) { return retailer!.first; } return null; } Map toJson() { Map data = {}; data['page'] = page; data['pages_count'] = pagesCount; data['per_page'] = perPage; if (locations != null) { data['locations'] = locations!.map((v) => v.toJson()).toList(); } if (offers != null) { data['offers'] = offers!.map((v) => v.toJson()).toList(); } if (retailer != null) { data['retailer'] = retailer!.map((v) => v.toJson()).toList(); } return data; } } class WalaPlusRetailerModel { String? id; String? name; String? offerTitle; String? offerType; String? description; String? info; String? image; String? imageThumb; String? logo; String? logoThumb; String? website; String? type; dynamic usage; dynamic weight; dynamic geoId; String? pin; String? barcodeStandard; String? arHowToUse; String? enHowToUse; String? howToUse; WalaPlusRetailerContact? contact; dynamic offerWeight; List? offers; List? locations; List? categories; String? categoryId; String? categoryName; String? validity; String? validTo; String? validUntil; String? endDate; String? expiryDate; String? duration; String? email; String? phone; String? countryNameAr; String? countryNameEn; bool? isFavorite; // Drives the "MAZAYA EXCLUSIVE" badge on the retailer card (Figma node 927:496). NOT parsed // from the WalaPlus_Retailers/WalaPlus_RetailersByID API - this is a different, separate // "Exclusive" API/section, so callers set this manually on the model after fetching from there. bool? isExclusive; WalaPlusRetailerModel({ this.id, this.name, this.offerTitle, this.offerType, this.description, this.info, this.image, this.imageThumb, this.logo, this.logoThumb, this.website, this.isFavorite, this.isExclusive, this.type, this.usage, this.weight, this.geoId, this.pin, this.barcodeStandard, this.arHowToUse, this.enHowToUse, this.howToUse, this.contact, this.offerWeight, this.offers, this.locations, this.categories, this.categoryId, this.categoryName, this.validity, this.validTo, this.validUntil, this.endDate, this.expiryDate, this.duration, this.email, this.phone, this.countryNameAr, this.countryNameEn, }); static List parseOffersList(data) { List list = []; if (data == null) return list; if (data is String) { try { data = jsonDecode(data); } catch (_) {} } if (data is List) { for (var v in data) { if (v is Map) { list.add(WalaPlusOfferModel.fromJson(v)); } else if (v is Map) { list.add(WalaPlusOfferModel.fromJson(Map.from(v))); } } } return list; } static List parseLocationsList(data) { List list = []; if (data == null) return list; if (data is String) { try { data = jsonDecode(data); } catch (_) {} } if (data is List) { for (var v in data) { if (v is Map) { list.add(WalaPlusRetailerLocation.fromJson(v)); } else if (v is Map) { list.add(WalaPlusRetailerLocation.fromJson(Map.from(v))); } } } return list; } factory WalaPlusRetailerModel.fromJson(Map json) { Map targetJson = json; dynamic offersSource = json['offers'] ?? json['Offers']; dynamic locationsSource = json['locations'] ?? json['Locations'] ?? json['location'] ?? json['Location']; // If json is the wrapper object from WalaPlus_RetailersByID containing 'retailer' key if (json['retailer'] != null || json['Retailer'] != null) { dynamic retailerData = json['retailer'] ?? json['Retailer']; if (retailerData is List && retailerData.isNotEmpty) { var first = retailerData.first; if (first is Map) { targetJson = first; } else if (first is Map) { targetJson = Map.from(first); } } else if (retailerData is Map) { targetJson = retailerData; } else if (retailerData is Map) { targetJson = Map.from(retailerData); } if (locationsSource == null || (locationsSource is List && locationsSource.isEmpty)) { locationsSource = targetJson['locations'] ?? targetJson['Locations'] ?? targetJson['location'] ?? targetJson['Location']; } if (offersSource == null || (offersSource is List && offersSource.isEmpty)) { offersSource = targetJson['offers'] ?? targetJson['Offers']; } } List offersList = parseOffersList(offersSource ?? targetJson['offers'] ?? targetJson['Offers']); List locationsList = parseLocationsList(locationsSource ?? targetJson['locations'] ?? targetJson['Locations'] ?? targetJson['location'] ?? targetJson['Location']); List categoriesList = []; dynamic rawCats = targetJson['categories'] ?? targetJson['Categories'] ?? targetJson['category'] ?? targetJson['Category']; if (rawCats != null && rawCats is List) { for (var v in rawCats) { if (v is Map) { categoriesList.add(WalaPlusCategoryModel.fromJson(v)); } else if (v is Map) { categoriesList.add(WalaPlusCategoryModel.fromJson(Map.from(v))); } else if (v is String && v.trim().isNotEmpty) { categoriesList.add(WalaPlusCategoryModel(name: v.trim())); } } } else if (rawCats is Map) { categoriesList.add(WalaPlusCategoryModel.fromJson(Map.from(rawCats))); } else if (rawCats is String && rawCats.toString().trim().isNotEmpty && rawCats.toString().trim() != "null") { categoriesList.add(WalaPlusCategoryModel(name: rawCats.toString().trim())); } String? singleCatName = (targetJson['category_name'] ?? targetJson['categoryName'] ?? targetJson['cat_name'] ?? targetJson['catName'] ?? targetJson['CategoryName'] ?? targetJson['Category_Name'] ?? targetJson['category_title'] ?? targetJson['categoryTitle']) ?.toString() .trim(); String? singleCatAr = (targetJson['category_name_ar'] ?? targetJson['categoryNameAr'] ?? targetJson['cat_name_ar'] ?? targetJson['catNameAr'] ?? targetJson['category_ar'] ?? targetJson['country_name_ar'] ?? targetJson['countryNameAr']) ?.toString() .trim(); String? singleCatEn = (targetJson['category_name_en'] ?? targetJson['categoryNameEn'] ?? targetJson['cat_name_en'] ?? targetJson['catNameEn'] ?? targetJson['category_en'] ?? targetJson['country_name_en'] ?? targetJson['countryNameEn']) ?.toString() .trim(); String? foundCategoryId = (targetJson['category_id'] ?? targetJson['categoryId'] ?? targetJson['cat_id'] ?? targetJson['catId'] ?? targetJson['WP_CategoryID'] ?? targetJson['WP_CategoryId'])?.toString().trim(); String? rawType = targetJson['type']?.toString().trim(); String? lowerType = rawType?.toLowerCase(); bool isTypeStoreKeyword = lowerType == null || lowerType.isEmpty || lowerType == 'online' || lowerType == 'instore' || lowerType == 'in_store' || lowerType == 'in store' || lowerType == 'store' || lowerType == 'online,instore' || lowerType == 'instore,online'; if ((singleCatName == null || singleCatName.isEmpty) && !isTypeStoreKeyword && rawType != null && rawType.isNotEmpty) { singleCatName = rawType; } if (categoriesList.isEmpty) { if ((singleCatName != null && singleCatName.isNotEmpty && singleCatName != "null") || (singleCatAr != null && singleCatAr.isNotEmpty && singleCatAr != "null") || (singleCatEn != null && singleCatEn.isNotEmpty && singleCatEn != "null")) { categoriesList.add( WalaPlusCategoryModel(id: foundCategoryId, name: singleCatName ?? singleCatEn ?? singleCatAr, countryNameAr: singleCatAr ?? singleCatName, countryNameEn: singleCatEn ?? singleCatName), ); } } WalaPlusRetailerContact? contactObj; if (targetJson['contact'] != null) { if (targetJson['contact'] is Map) { contactObj = WalaPlusRetailerContact.fromJson(Map.from(targetJson['contact'])); } else if (targetJson['contact'] is String && targetJson['contact'].toString().trim().isNotEmpty) { contactObj = WalaPlusRetailerContact(website: targetJson['contact'].toString()); } } String? foundValidity = targetJson['validity']?.toString() ?? targetJson['valid_to']?.toString() ?? targetJson['valid_until']?.toString() ?? targetJson['end_date']?.toString() ?? targetJson['expiry_date']?.toString() ?? targetJson['expire_date']?.toString() ?? targetJson['duration']?.toString() ?? targetJson['validity_period']?.toString() ?? targetJson['valid_duration']?.toString() ?? targetJson['offer_validity']?.toString(); if (foundValidity == null || foundValidity == "null" || foundValidity.trim().isEmpty) { for (var entry in targetJson.entries) { String k = entry.key.toLowerCase(); if ((k.contains('valid') || k.contains('expir') || k.contains('duration')) && !k.contains('isverificationcodevalid') && !k.contains('barcode')) { if (entry.value != null && entry.value.toString().trim().isNotEmpty && entry.value.toString() != "null") { foundValidity = entry.value.toString().trim(); break; } } } } String? foundWebsite = targetJson['website']?.toString() ?? targetJson['merchant_website']?.toString() ?? targetJson['merchantWebsite']?.toString() ?? targetJson['website_url']?.toString() ?? targetJson['websiteUrl']?.toString() ?? targetJson['web_site']?.toString() ?? targetJson['url']?.toString() ?? targetJson['merchant_url']?.toString() ?? targetJson['merchantUrl']?.toString() ?? targetJson['merchantLink']?.toString() ?? targetJson['merchant_link']?.toString() ?? targetJson['link']?.toString() ?? contactObj?.website; if (foundWebsite == null || foundWebsite.trim().isEmpty || foundWebsite == "null") { if (targetJson['merchant'] is Map) { var m = targetJson['merchant']; foundWebsite = (m['website'] ?? m['merchant_website'] ?? m['url'] ?? m['link'])?.toString(); } } if (foundWebsite == null || foundWebsite.trim().isEmpty || foundWebsite == "null") { for (var entry in targetJson.entries) { String k = entry.key.toLowerCase(); if ((k.contains('website') || k.contains('web_site') || k == 'url' || k.contains('merchant_url')) && !k.contains('image') && !k.contains('logo')) { if (entry.value != null && entry.value.toString().trim().isNotEmpty && entry.value.toString() != "null") { foundWebsite = entry.value.toString().trim(); break; } } } } bool? parsedIsFavorite; if (targetJson.containsKey('isfavorite')) { parsedIsFavorite = targetJson['isfavorite'] == true || targetJson['isfavorite'] == 1 || targetJson['isfavorite'] == 'true' || targetJson['isfavorite'] == '1'; } else if (targetJson.containsKey('is_favorite')) { parsedIsFavorite = targetJson['is_favorite'] == true || targetJson['is_favorite'] == 1 || targetJson['is_favorite'] == 'true' || targetJson['is_favorite'] == '1'; } else if (targetJson.containsKey('isFavorite')) { parsedIsFavorite = targetJson['isFavorite'] == true || targetJson['isFavorite'] == 1 || targetJson['isFavorite'] == 'true' || targetJson['isFavorite'] == '1'; } else if (targetJson.containsKey('favorite')) { parsedIsFavorite = targetJson['favorite'] == true || targetJson['favorite'] == 1 || targetJson['favorite'] == 'true' || targetJson['favorite'] == '1'; } return WalaPlusRetailerModel( id: targetJson['id']?.toString() ?? targetJson['WP_RetailerID']?.toString() ?? targetJson['retailer_id']?.toString() ?? targetJson['RetailerID']?.toString() ?? targetJson['retailerId']?.toString(), name: decodeUnicodeString(targetJson['name']), offerTitle: decodeUnicodeString(targetJson['offer_title']), offerType: targetJson['offer_type']?.toString(), description: decodeUnicodeString(targetJson['description']), info: decodeUnicodeString(targetJson['info']), image: targetJson['image']?.toString(), imageThumb: targetJson['image_thumb']?.toString(), logo: targetJson['logo']?.toString(), logoThumb: targetJson['logo_thumb']?.toString(), website: foundWebsite, isFavorite: parsedIsFavorite, type: targetJson['type']?.toString(), usage: targetJson['usage'] is String ? decodeUnicodeString(targetJson['usage']) : targetJson['usage'], weight: targetJson['weight'], geoId: targetJson['geo_id'], pin: targetJson['pin']?.toString(), barcodeStandard: targetJson['barcode_standard']?.toString(), arHowToUse: decodeUnicodeString(targetJson['ar_how_to_use']), enHowToUse: decodeUnicodeString(targetJson['en_how_to_use']), howToUse: decodeUnicodeString(targetJson['how_to_use']), contact: contactObj, offerWeight: targetJson['offer_weight'], offers: offersList, locations: locationsList, categories: categoriesList, categoryId: foundCategoryId, categoryName: singleCatName ?? singleCatEn ?? singleCatAr, validity: foundValidity, validTo: targetJson['valid_to']?.toString() ?? targetJson['validTo']?.toString(), validUntil: targetJson['valid_until']?.toString() ?? targetJson['validUntil']?.toString(), endDate: targetJson['end_date']?.toString() ?? targetJson['endDate']?.toString(), expiryDate: targetJson['expiry_date']?.toString() ?? targetJson['expiryDate']?.toString(), duration: targetJson['duration']?.toString(), email: targetJson['email']?.toString() ?? targetJson['contact_email']?.toString() ?? contactObj?.primaryEmail, phone: targetJson['phone']?.toString() ?? targetJson['contact_phone']?.toString() ?? targetJson['mobile']?.toString() ?? contactObj?.primaryPhone, countryNameAr: decodeUnicodeString(targetJson['country_name_ar'] ?? targetJson['countryNameAr'] ?? targetJson['country_ar']), countryNameEn: decodeUnicodeString(targetJson['country_name_en'] ?? targetJson['countryNameEn'] ?? targetJson['country_en']), ); } // Adapter for the Mazaya "Exclusive Offers" section, which reuses the legacy Offers & // Discounts (ITG) API/implementation (lib/ui/screens/offers_and_discounts) rather than a // WalaPlus endpoint. Maps whatever overlaps onto this model so the existing grid/card/detail // screen can render it unchanged - fields with no equivalent on OffersListModel (favorite, // redeem, locations, contact, ...) are simply left null, and isExclusive hides the actions // that depend on them (see WalaPlusRetailerCard, RetailerDetailsScreen). factory WalaPlusRetailerModel.fromExclusiveOffer(OffersListModel offer, {bool isAr = false}) { String? name = decodeUnicodeString(isAr ? (offer.titleAr ?? offer.titleEn) : (offer.titleEn ?? offer.titleAr)); String? description = decodeUnicodeString(isAr ? (offer.descriptionAr ?? offer.descriptionEn) : (offer.descriptionEn ?? offer.descriptionAr)); String? categoryName = decodeUnicodeString(isAr ? (offer.categoryNameAr ?? offer.categoryNameEn) : (offer.categoryNameEn ?? offer.categoryNameAr)); String? discountText = _formatExclusiveDiscountText(decodeUnicodeString(offer.discountDescription) ?? decodeUnicodeString(offer.discount)); return WalaPlusRetailerModel( id: "exclusive-${offer.offersDiscountId ?? ''}", name: name, // WalaPlusRetailerCard reads offerTitle as the promo subtitle shown under the name (e.g. // "20% off") - the discount text is the closest equivalent on a legacy offer. offerTitle: discountText, description: description, info: description, image: offer.bannerImage, logo: offer.logo, website: offer.websiteUrl, endDate: offer.endDate, validTo: offer.endDate, categoryName: categoryName, categories: categoryName != null ? [WalaPlusCategoryModel(name: categoryName)] : null, isExclusive: true, ); } // The legacy Offers & Discounts API sometimes returns a bare number ("10", "20") for its // discount fields rather than a formatted "10% off" string. WalaPlusRetailerCard's badge // logic (getDiscountBadge) only recognizes a "%" pattern, so a bare number would // otherwise render verbatim instead of as a discount. Already-formatted text (containing "%", // "off", or "خصم") is left untouched. static String? _formatExclusiveDiscountText(String? raw) { if (raw == null) return null; String trimmed = raw.trim(); if (trimmed.isEmpty) return null; if (trimmed.contains('%') || trimmed.toLowerCase().contains('off') || trimmed.contains('خصم')) { return trimmed; } if (RegExp(r'^\d+(\.\d+)?$').hasMatch(trimmed)) { return LocaleKeys.offDiscount.tr(args: ['$trimmed%']); } return trimmed; } static String? decodeUnicodeString(Object? val) { if (val == null) return null; String s = val.toString().trim(); if (s.isEmpty || s == "null") return null; while ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { if (s.length >= 2) { s = s.substring(1, s.length - 1).trim(); } else { break; } } s = s.replaceAll(r'\/', '/').replaceAll(r'\"', '"').replaceAll(r"\'", "'"); s = s.replaceAllMapped(RegExp(r'\\u([0-9a-fA-F]{4})'), (match) { try { var codeUnit = int.parse(match.group(1)!, radix: 16); return String.fromCharCode(codeUnit); } catch (_) { return match.group(0)!; } }); s = s .replaceAll(r'\u201c', '“') .replaceAll(r'\u201d', '”') .replaceAll(r'\u2019', '’') .replaceAll(r'\u2018', '‘') .replaceAll(r'\u00a0', ' ') .replaceAll(r'\u2022', '•') .replaceAll(r'\u2013', '–') .replaceAll(r'\u2014', '—') .replaceAll(r'\u200f', '') .replaceAll(r'\u200e', '') .replaceAll(r'\u061f', '؟') .replaceAll(r'\u201', '“') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll(''', "'") .replaceAll(' ', ' ') .replaceAll('&', '&'); return s; } Map toJson() { Map data = {}; data['id'] = id; data['name'] = name; data['offer_title'] = offerTitle; data['offer_type'] = offerType; data['description'] = description; data['info'] = info; data['image'] = image; data['image_thumb'] = imageThumb; data['logo'] = logo; data['logo_thumb'] = logoThumb; data['website'] = website; data['type'] = type; data['usage'] = usage; data['weight'] = weight; data['geo_id'] = geoId; data['pin'] = pin; data['barcode_standard'] = barcodeStandard; data['ar_how_to_use'] = arHowToUse; data['en_how_to_use'] = enHowToUse; data['how_to_use'] = howToUse; data['contact'] = contact?.toJson(); data['offer_weight'] = offerWeight; data['validity'] = validity; data['valid_to'] = validTo; data['valid_until'] = validUntil; data['end_date'] = endDate; data['expiry_date'] = expiryDate; data['duration'] = duration; data['email'] = email; data['phone'] = phone; data['country_name_ar'] = countryNameAr; data['country_name_en'] = countryNameEn; data['is_favorite'] = isFavorite; data['isfavorite'] = isFavorite; data['is_exclusive'] = isExclusive; if (offers != null) { data['offers'] = offers!.map((v) => v.toJson()).toList(); } if (locations != null) { data['locations'] = locations!.map((v) => v.toJson()).toList(); } if (categories != null) { data['categories'] = categories!.map((v) => v.toJson()).toList(); } data['category_id'] = categoryId; data['category_name'] = categoryName; return data; } String? getDisplayCategory({bool isAr = false, List? fallbackCategories}) { if (categories != null && categories!.isNotEmpty) { for (var cat in categories!) { String? primary = isAr ? cat.countryNameAr : cat.countryNameEn; String? secondary = isAr ? (cat.name ?? cat.countryNameEn) : (cat.name ?? cat.countryNameAr); String? resolved = (primary != null && primary.trim().isNotEmpty && primary != "null") ? primary.trim() : (secondary != null && secondary.trim().isNotEmpty && secondary != "null" ? secondary.trim() : null); if (resolved != null && resolved.isNotEmpty) { return resolved; } } } if (categoryName != null && categoryName!.trim().isNotEmpty && categoryName != "null") { return categoryName!.trim(); } if (fallbackCategories != null && categoryId != null && categoryId!.trim().isNotEmpty) { for (var cat in fallbackCategories) { if (cat.id?.toString() == categoryId?.toString()) { String? primary = isAr ? cat.countryNameAr : cat.countryNameEn; String? secondary = isAr ? (cat.name ?? cat.countryNameEn) : (cat.name ?? cat.countryNameAr); String? resolved = (primary != null && primary.trim().isNotEmpty && primary != "null") ? primary.trim() : (secondary != null && secondary.trim().isNotEmpty && secondary != "null" ? secondary.trim() : null); if (resolved != null && resolved.isNotEmpty) { return resolved; } } } } if (type != null && type!.trim().isNotEmpty) { String lt = type!.trim().toLowerCase(); if (lt != 'online' && lt != 'instore' && lt != 'store' && lt != 'in_store') { return type!.trim(); } } return null; } String? get displayImage { if (image != null && image!.trim().isNotEmpty && image != "null") return image!.trim(); if (imageThumb != null && imageThumb!.trim().isNotEmpty && imageThumb != "null") return imageThumb!.trim(); return null; } String? get displayLogo { if (logo != null && logo!.trim().isNotEmpty && logo != "null") return logo!.trim(); if (logoThumb != null && logoThumb!.trim().isNotEmpty && logoThumb != "null") return logoThumb!.trim(); return null; } String? get resolvedWebsite { if (website != null && website!.trim().isNotEmpty && website != "null") { return website!.trim(); } if (contact?.website != null && contact!.website!.trim().isNotEmpty && contact!.website != "null") { return contact!.website!.trim(); } return null; } bool get hasPhysicalLocation { if (locations != null && locations!.isNotEmpty) { for (var loc in locations!) { if ((loc.cityNameEn != null && loc.cityNameEn!.trim().isNotEmpty && loc.cityNameEn != "null") || (loc.cityNameAr != null && loc.cityNameAr!.trim().isNotEmpty && loc.cityNameAr != "null") || (loc.city != null && loc.city!.trim().isNotEmpty && loc.city != "null")) { return true; } } } return false; } // Resolves a single location's city using the same 3-pass priority this used to apply // globally: specific city name first, then a non-generic-country city, then any city at all. String? _resolveLocationCity(WalaPlusRetailerLocation loc, bool isAr) { String? cName = isAr ? loc.cityNameAr : loc.cityNameEn; if (cName != null && cName.trim().isNotEmpty && cName != "null") { return cName.trim(); } if (loc.city != null && loc.city!.trim().isNotEmpty && loc.city != "null") { String c = loc.city!.trim(); if (c.toLowerCase() != 'saudi arabia' && c != 'المملكة العربية السعودية') { return c; } } if (loc.city != null && loc.city!.trim().isNotEmpty && loc.city != "null") { return loc.city!.trim(); } return null; } // Multiple distinct cities across this retailer's locations -> "Multiple Cities" instead of // just picking the first one found, so the badge doesn't silently hide the other branches. String getDisplayCity({bool isAr = false}) { if (locations == null || locations!.isEmpty) return ''; Set distinctCities = {}; for (var loc in locations!) { String? resolved = _resolveLocationCity(loc, isAr); if (resolved != null) distinctCities.add(resolved); } if (distinctCities.isEmpty) return ''; if (distinctCities.length > 1) return LocaleKeys.multipleCities.tr(); return distinctCities.first; } String get displayCity => getDisplayCity(isAr: false); String get displayUsage { if (usage != null) { String u = usage.toString().trim(); if (int.tryParse(u) != null) { return 'usage:$u'; } if (!u.contains('<') && !u.contains('{') && u.isNotEmpty && u.length <= 12 && u.toLowerCase() != 'online' && u.toLowerCase() != 'store') { return 'usage:$u'; } } return ''; } String getDisplayValidity({bool isAr = false}) { if (validity != null && validity!.trim().isNotEmpty && validity != "null") { return validity!.trim(); } if (validTo != null && validTo!.trim().isNotEmpty && validTo != "null") { return validTo!.trim(); } if (validUntil != null && validUntil!.trim().isNotEmpty && validUntil != "null") { return validUntil!.trim(); } if (endDate != null && endDate!.trim().isNotEmpty && endDate != "null") { return endDate!.trim(); } if (expiryDate != null && expiryDate!.trim().isNotEmpty && expiryDate != "null") { return expiryDate!.trim(); } if (duration != null && duration!.trim().isNotEmpty && duration != "null") { return duration!.trim(); } return LocaleKeys.offerValid.tr(); } String? get resolvedEmail { if (email != null && email!.trim().isNotEmpty && email != "null") { return email!.trim(); } if (contact?.primaryEmail != null && contact!.primaryEmail!.trim().isNotEmpty && contact!.primaryEmail != "null") { return contact!.primaryEmail!.trim(); } if (locations != null && locations!.isNotEmpty) { for (var loc in locations!) { if (loc.contact != null && loc.contact!.contains('@')) { return loc.contact!.trim(); } } } String combined = "${description ?? ''} ${info ?? ''} ${howToUse ?? ''} ${arHowToUse ?? ''} ${enHowToUse ?? ''}"; RegExp emailRegex = RegExp(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'); Match? match = emailRegex.firstMatch(combined); if (match != null) { return match.group(0); } return null; } String? get resolvedPhone { if (phone != null && phone!.trim().isNotEmpty && phone != "null") { return phone!.trim(); } if (contact?.primaryPhone != null && contact!.primaryPhone!.trim().isNotEmpty && contact!.primaryPhone != "null") { return contact!.primaryPhone!.trim(); } if (locations != null && locations!.isNotEmpty) { for (var loc in locations!) { if (loc.contact != null && loc.contact!.trim().isNotEmpty && loc.contact != "null" && !loc.contact!.contains('@')) { return loc.contact!.trim(); } } } String combined = "${description ?? ''} ${info ?? ''} ${howToUse ?? ''} ${arHowToUse ?? ''} ${enHowToUse ?? ''}"; RegExp phoneRegex = RegExp(r'(?:\+?966|0)?(?:5\d{8}|1\d{7}|9200\d{5}|800\d{6,7})'); Match? match = phoneRegex.firstMatch(combined); if (match != null) { return match.group(0); } return null; } // type can be a comma-separated combo (e.g. "online,instore") when a retailer offers both - // this splits and normalizes every part instead of only ever matching a single exact value, // so a combined retailer is correctly recognized as both rather than neither/just one. List get normalizedTypes { Set result = {}; if (type != null && type!.trim().isNotEmpty) { for (String part in type!.split(',')) { String t = part.trim().toLowerCase(); if (t.isEmpty) continue; if (t == 'online') { result.add('online'); } else if (t == 'instore' || t == 'in_store' || t == 'in store' || t == 'store') { result.add('instore'); } else { // Non-online types (e.g. category names like "Physiotherapy") represent // in-store merchants/services that require physical presence. result.add('instore'); } } } if (locations != null && locations!.isNotEmpty) { result.add('instore'); } if (result.isEmpty && hasPhysicalLocation) { result.add('instore'); } return result.toList(); } bool get isOnlineStore => normalizedTypes.contains('online'); bool get isInstore => normalizedTypes.contains('instore'); // Kept for any existing single-value callers - returns the first normalized type found. String? get normalizedType { List types = normalizedTypes; return types.isEmpty ? null : types.first; } String _displayTypeLabel(String norm) { if (norm == 'online') return LocaleKeys.online.tr(); return LocaleKeys.inStore.tr(); } String? getDisplayType() { List types = normalizedTypes; if (types.isNotEmpty) return _displayTypeLabel(types.first); return null; } // One label per distinct type (e.g. ["Online", "In Store"]) - used to render a separate chip // for each, instead of collapsing a dual-type retailer down to a single badge. List getDisplayTypes() { List types = normalizedTypes; if (types.isNotEmpty) return types.map(_displayTypeLabel).toList(); return []; } } class WalaPlusRetailerContact { String? email; List? emails; String? phone; List? phones; String? website; String? address; String? instagram; String? twitter; WalaPlusRetailerContact({this.email, this.emails, this.phone, this.phones, this.website, this.address, this.instagram, this.twitter}); factory WalaPlusRetailerContact.fromJson(Map json) { List emailsList = []; if (json['emails'] != null && json['emails'] is List) { json['emails'].forEach((v) => emailsList.add(v.toString())); } String? singleEmail = json['email']?.toString() ?? json['email_address']?.toString(); if (singleEmail != null && singleEmail.trim().isNotEmpty && singleEmail != "null") { if (!emailsList.contains(singleEmail.trim())) { emailsList.add(singleEmail.trim()); } } List phonesList = []; if (json['phones'] != null && json['phones'] is List) { json['phones'].forEach((v) => phonesList.add(v.toString())); } String? singlePhone = json['phone']?.toString() ?? json['mobile']?.toString() ?? json['phone_number']?.toString(); if (singlePhone != null && singlePhone.trim().isNotEmpty && singlePhone != "null") { if (!phonesList.contains(singlePhone.trim())) { phonesList.add(singlePhone.trim()); } } return WalaPlusRetailerContact( email: singleEmail, emails: emailsList, phone: singlePhone, phones: phonesList, website: WalaPlusRetailerModel.decodeUnicodeString(json['website']), address: WalaPlusRetailerModel.decodeUnicodeString(json['address']), instagram: json['instagram']?.toString(), twitter: json['twitter']?.toString(), ); } Map toJson() { return {'email': email, 'emails': emails, 'phone': phone, 'phones': phones, 'website': website, 'address': address, 'instagram': instagram, 'twitter': twitter}; } String? get primaryEmail { if (email != null && email!.trim().isNotEmpty && email != "null") return email!.trim(); if (emails != null && emails!.isNotEmpty) { for (var e in emails!) { if (e.trim().isNotEmpty && e != "null") return e.trim(); } } return null; } String? get primaryPhone { if (phone != null && phone!.trim().isNotEmpty && phone != "null") return phone!.trim(); if (phones != null && phones!.isNotEmpty) { for (var p in phones!) { if (p.trim().isNotEmpty && p != "null") return p.trim(); } } return null; } } class WalaPlusRetailerLocation { dynamic id; String? address; String? city; String? cityNameAr; String? cityNameEn; String? contact; String? countryNameAr; String? countryNameEn; String? distance; double? latitude; double? longitude; dynamic placeId; WalaPlusRetailerLocation({ this.id, this.address, this.city, this.cityNameAr, this.cityNameEn, this.contact, this.countryNameAr, this.countryNameEn, this.distance, this.latitude, this.longitude, this.placeId, }); factory WalaPlusRetailerLocation.fromJson(Map json) { return WalaPlusRetailerLocation( id: json['id'], address: WalaPlusRetailerModel.decodeUnicodeString(json['address']), city: WalaPlusRetailerModel.decodeUnicodeString(json['city']), cityNameAr: WalaPlusRetailerModel.decodeUnicodeString(json['city_name_ar']), cityNameEn: WalaPlusRetailerModel.decodeUnicodeString(json['city_name_en']), contact: json['contact']?.toString(), countryNameAr: WalaPlusRetailerModel.decodeUnicodeString(json['country_name_ar']), countryNameEn: WalaPlusRetailerModel.decodeUnicodeString(json['country_name_en']), distance: WalaPlusRetailerModel.decodeUnicodeString(json['distance']), latitude: json['latitude'] is num ? (json['latitude'] as num).toDouble() : double.tryParse(json['latitude']?.toString() ?? ''), longitude: json['longitude'] is num ? (json['longitude'] as num).toDouble() : double.tryParse(json['longitude']?.toString() ?? ''), placeId: json['place_id'], ); } Map toJson() { Map data = {}; data['id'] = id; data['address'] = address; data['city'] = city; data['city_name_ar'] = cityNameAr; data['city_name_en'] = cityNameEn; data['contact'] = contact; data['country_name_ar'] = countryNameAr; data['country_name_en'] = countryNameEn; data['distance'] = distance; data['latitude'] = latitude; data['longitude'] = longitude; data['place_id'] = placeId; return data; } // distance comes from the API as a raw numeric string (e.g. "14.729670498669968") - // round to max 2 decimal places and append the localized "KM Away" label. String? getDisplayDistance() { if (distance == null) return null; double? km = double.tryParse(distance!.trim()); if (km == null) return null; String formatted = km.toStringAsFixed(2); return LocaleKeys.kmAway.tr(args: [formatted]); } String getLocalizedCity(bool isAr) { if (isAr) { if (cityNameAr != null && cityNameAr!.trim().isNotEmpty && cityNameAr != "null") { return cityNameAr!.trim(); } } else { if (cityNameEn != null && cityNameEn!.trim().isNotEmpty && cityNameEn != "null") { return cityNameEn!.trim(); } } if (city != null && city!.trim().isNotEmpty && city != "null") { return city!.trim(); } return isAr ? (cityNameAr ?? "") : (cityNameEn ?? ""); } String getLocalizedCountry(bool isAr) { if (isAr) { if (countryNameAr != null && countryNameAr!.trim().isNotEmpty && countryNameAr != "null") { return countryNameAr!.trim(); } } else { if (countryNameEn != null && countryNameEn!.trim().isNotEmpty && countryNameEn != "null") { return countryNameEn!.trim(); } } return ""; } } class WalaPlusOfferModel { String? id; String? retailerId; String? title; String? image; String? imageThumb; String? type; String? description; dynamic price; dynamic usage; String? expiredAt; WalaPlusOfferModel({this.id, this.retailerId, this.title, this.image, this.imageThumb, this.type, this.description, this.price, this.usage, this.expiredAt}); factory WalaPlusOfferModel.fromJson(Map json) { return WalaPlusOfferModel( id: json['id']?.toString(), retailerId: (json['retailer_id'] ?? json['retailerId'] ?? json['retailerID'])?.toString(), title: WalaPlusRetailerModel.decodeUnicodeString(json['title']), image: json['image']?.toString(), imageThumb: (json['image_thumb'] ?? json['imageThumb'])?.toString(), type: json['type']?.toString(), description: WalaPlusRetailerModel.decodeUnicodeString(json['description']), price: json['price'], usage: json['usage'], expiredAt: (json['expired_at'] ?? json['expiredAt'] ?? json['expiry_date'])?.toString(), ); } Map toJson() { return { 'id': id, 'retailer_id': retailerId, 'title': title, 'image': image, 'image_thumb': imageThumb, 'type': type, 'description': description, 'price': price, 'usage': usage, 'expired_at': expiredAt, }; } String? get displayImage { if (image != null && image!.trim().isNotEmpty && image != "null") return image!.trim(); if (imageThumb != null && imageThumb!.trim().isNotEmpty && imageThumb != "null") return imageThumb!.trim(); return null; } }