import 'dart:developer'; import 'dart:ui' as ui; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_svg/flutter_svg.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/extensions/widget_extensions.dart'; import 'package:mohem_flutter_app/generated/locale_keys.g.dart'; import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart'; import 'package:mohem_flutter_app/widgets/app_bar_widget.dart'; import 'package:mohem_flutter_app/widgets/app_in_app_webview_screen.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:permission_handler/permission_handler.dart'; import 'package:url_launcher/url_launcher.dart'; class WalaPlusRetailerDetailsScreen extends StatefulWidget { final WalaPlusRetailerModel? retailer; final String? retailerId; const WalaPlusRetailerDetailsScreen({Key? key, this.retailer, this.retailerId}) : super(key: key); @override State createState() => _WalaPlusRetailerDetailsScreenState(); } class _WalaPlusRetailerDetailsScreenState extends State { WalaPlusRetailerModel? _retailer; bool _isLoading = true; bool _hasDetailsData = false; String? _effectiveRetailerId; bool _hasFetched = false; bool _isRedeeming = false; @override void initState() { super.initState(); _retailer = widget.retailer; _effectiveRetailerId = widget.retailerId ?? widget.retailer?.id; } @override void didChangeDependencies() { super.didChangeDependencies(); if (_effectiveRetailerId == null) { Object? args = ModalRoute.of(context)?.settings.arguments; if (args is WalaPlusRetailerModel) { _retailer ??= args; _effectiveRetailerId = args.id; } else if (args is String) { _effectiveRetailerId = args; } } if (!_hasFetched && _effectiveRetailerId != null) { _hasFetched = true; _fetchRetailerDetails(); } } Future _fetchRetailerDetails() async { if (_effectiveRetailerId == null) { if (mounted) { setState(() { _isLoading = false; }); } return; } setState(() { _isLoading = true; }); try { debugPrint("WalaPlusRetailerDetailsScreen: fetching details for retailer ID: $_effectiveRetailerId"); WalaPlusRetailerModel? result = await OffersAndDiscountsApiClient().getWalaPlusRetailerById(retailerId: _effectiveRetailerId!, context: context); if (result != null && mounted) { if ((result.image == null || result.image!.isEmpty || result.image == "null") && _retailer?.image != null) { result.image = _retailer!.image; } if ((result.imageThumb == null || result.imageThumb!.isEmpty || result.imageThumb == "null") && _retailer?.imageThumb != null) { result.imageThumb = _retailer!.imageThumb; } if ((result.logo == null || result.logo!.isEmpty || result.logo == "null") && _retailer?.logo != null) { result.logo = _retailer!.logo; } if ((result.logoThumb == null || result.logoThumb!.isEmpty || result.logoThumb == "null") && _retailer?.logoThumb != null) { result.logoThumb = _retailer!.logoThumb; } if ((result.name == null || result.name!.isEmpty) && _retailer?.name != null) { result.name = _retailer!.name; } if ((result.type == null || result.type!.isEmpty) && _retailer?.type != null) { result.type = _retailer!.type; } if ((result.locations == null || result.locations!.isEmpty) && _retailer?.locations != null && _retailer!.locations!.isNotEmpty) { result.locations = _retailer!.locations; } if ((result.offers == null || result.offers!.isEmpty) && _retailer?.offers != null && _retailer!.offers!.isNotEmpty) { result.offers = _retailer!.offers; } if ((result.validity == null || result.validity!.isEmpty) && _retailer?.validity != null) { result.validity = _retailer!.validity; } if ((result.offerTitle == null || result.offerTitle!.isEmpty) && _retailer?.offerTitle != null) { result.offerTitle = _retailer!.offerTitle; } if (result.contact == null && _retailer?.contact != null) { result.contact = _retailer!.contact; } if ((result.email == null || result.email!.isEmpty) && _retailer?.email != null) { result.email = _retailer!.email; } if ((result.phone == null || result.phone!.isEmpty) && _retailer?.phone != null) { result.phone = _retailer!.phone; } setState(() { _retailer = result; _hasDetailsData = true; }); } } catch (e) { debugPrint("Error fetching WalaPlus retailer by ID: $e"); } finally { if (mounted) { setState(() { _isLoading = false; }); } } } Future _openInAppBrowser(String url, {String? offerType}) async { String title = _retailer?.name ?? LocaleKeys.offerDetails.tr(); bool shouldStartScanning = offerType?.trim().toLowerCase() == "discount"; log("shouldStartScanning: ${shouldStartScanning.toString()} for offerType: ${offerType.toString()}"); // Ask for camera permission app-side, before the WebView (and the page's own // getUserMedia() call) even exists. By the time the page asks, the OS decision is // already made, so onPermissionRequest can answer instantly - no mid-flight dialog // racing the page's own timeout for its original getUserMedia() call. // Discount (QR) offers need the camera to work at all, so if the user denies here, // don't open the transaction URL - promo offers never reach this check. if (shouldStartScanning) { try { PermissionStatus status = await Permission.camera.status; log("permission status: ${status.toString()}"); // Permanently denied: Permission.camera.request() won't show a dialog at all in this // state (Android just returns denied silently) - only the app settings screen can fix it. if (status.isPermanentlyDenied) { _showCameraPermissionSheet(LocaleKeys.cameraPermissionPermanentlyDenied.tr()); return; } if (!status.isGranted) { status = await Permission.camera.request(); } if (status.isPermanentlyDenied) { _showCameraPermissionSheet(LocaleKeys.cameraPermissionPermanentlyDenied.tr()); return; } if (!status.isGranted) { _showCameraPermissionSheet(LocaleKeys.cameraPermissionRequiredToScan.tr()); return; } } catch (e) { debugPrint("Error requesting camera permission before opening transaction URL: $e"); _showCameraPermissionSheet(LocaleKeys.cameraPermissionRequiredToScan.tr()); return; } } await Navigator.push( context, MaterialPageRoute(builder: (BuildContext context) => AppInAppWebViewScreen(url: url, title: title, onPageLoaded: shouldStartScanning ? _clickStartScanningButton : null)), ); } // showModalBottomSheet is dismissible by tapping outside by default (isDismissible: true), // so no extra wiring is needed for that. "Open Settings" is the only action - there's nothing // useful to retry from here, the user has to leave the app to fix this. void _showCameraPermissionSheet(String message) { ConfirmBottomSheet.show( context, message: message, confirmText: LocaleKeys.openSettings.tr(), showCancelButton: false, icon: Icons.videocam_off_outlined, isDestructive: false, onConfirm: () { openAppSettings(); }, ); } // Vue's @click handlers are component methods, not something on window, so we find the // actual button by its text and click it (lets Vue's own handler fire). // Two buttons matter here: "Start Scanning" (the initial trigger) and "Try Again" (shown // instead of "Start Scanning" if the camera permission flow takes a while - clicking it // retries the same flow). Either may need clicking more than once if permission stays // pending, so instead of a one-shot "clicked" flag we keep watching for as long as the // safety-net timeout allows, with a short cooldown after each click so a single button // occurrence can't get double-clicked from back-to-back mutation events before the page // has a chance to react. // This can be injected more than once (e.g. once on page load, again once the native camera // permission result comes back) - state lives on `window`, not in the closure, so a repeat // call never starts a second observer or spams clicks. // Only called for offerType "discount" - "promo" offers just show a promo code, no scanner. void _clickStartScanningButton(InAppWebViewController controller) { controller.evaluateJavascript( source: r""" (function() { var cooldownMs = 800; function tryClick() { var now = Date.now(); if (now - (window.__mohemmScanLastClickAt || 0) < cooldownMs) return; var buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var text = (buttons[i].textContent || '').replace(/\s+/g, ' ').trim(); if (text.indexOf('Start Scanning') !== -1 || text.indexOf('Try Again') !== -1) { buttons[i].click(); window.__mohemmScanLastClickAt = now; return; } } } tryClick(); if (window.__mohemmScanObserverActive) return; window.__mohemmScanObserverActive = true; var observer = new MutationObserver(function() { tryClick(); }); observer.observe(document.body, { childList: true, subtree: true }); // Safety net only - stop watching after a while so we don't observe forever // (e.g. permission denied for good, or user left the page). setTimeout(function() { observer.disconnect(); window.__mohemmScanObserverActive = false; }, 120000); })(); """, ); } void _onUseOffer() { if (_isRedeeming) return; List offers = _retailer?.offers ?? []; log("offers.length: ${offers.length}"); if (offers.length > 1) { _showOffersBottomSheet(offers); } else if (offers.length == 1) { WalaPlusOfferModel singleOffer = offers.first; String offerId = (singleOffer.id != null && singleOffer.id!.isNotEmpty) ? singleOffer.id! : (_retailer?.id ?? _effectiveRetailerId ?? ""); String offerType = (singleOffer.type != null && singleOffer.type!.isNotEmpty) ? singleOffer.type! : (_retailer?.offerType ?? "promo"); _redeemOffer(offerId: offerId, offerType: offerType); } else { String offerId = _retailer?.id ?? _effectiveRetailerId ?? ""; if (offerId.isEmpty) { Utils.showToast(LocaleKeys.offerIdNotAvailable.tr()); return; } _redeemOffer(offerId: offerId, offerType: _retailer?.offerType ?? "promo"); } } Future _redeemOffer({required String offerId, required String offerType}) async { if (_isRedeeming) return; if (offerId.isEmpty) { Utils.showToast(LocaleKeys.offerIdNotAvailable.tr()); return; } setState(() { _isRedeeming = true; }); Utils.showLoading(context); try { Map? res = await OffersAndDiscountsApiClient().redeemWalaPlusOffer(offerId: offerId, offerType: offerType, context: context); Utils.hideLoading(context); String? transactionUrl; if (res != null) { transactionUrl = res['Transaction_Url'] ?? res['TransactionUrl'] ?? res['transaction_url'] ?? res['transactionUrl'] ?? res['Url'] ?? res['url']; } log("transactionUrl: ${transactionUrl.toString()}"); if (transactionUrl != null && transactionUrl.trim().isNotEmpty) { await _openInAppBrowser(transactionUrl.trim(), offerType: offerType); } else { String? error = res?['Error']?.toString() ?? res?['error']?.toString() ?? res?['ErrorMessage']?.toString() ?? res?['message']?.toString(); if (error != null && error.isNotEmpty && error != "null") { Utils.showToast(error); } else { Utils.showToast(LocaleKeys.unableToRedeemOffer.tr()); } } } catch (e) { Utils.hideLoading(context); debugPrint("Error redeeming offer: $e"); Utils.showToast(LocaleKeys.errorRedeemingOffer.tr()); } finally { if (mounted) { setState(() { _isRedeeming = false; }); } else { _isRedeeming = false; } } } void _showOffersBottomSheet(List offers) { bool isAr = Directionality.of(context) == ui.TextDirection.rtl || AppState().isArabic(context); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (BuildContext sheetContext) { return Container( constraints: BoxConstraints(maxHeight: MediaQuery.of(sheetContext).size.height * 0.78), decoration: const BoxDecoration(color: Color(0xFFF8F9FA), borderRadius: BorderRadius.vertical(top: Radius.circular(24))), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ 12.height, Center(child: Container(width: 44, height: 4.5, decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)))), 12.height, Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ LocaleKeys.availableOffers.tr().toText18(isBold: true, color: const Color(0xFF1E293B)), 4.height, LocaleKeys.selectOfferToRedeem.tr().toText12(color: const Color(0xFF64748B)), ], ), ), IconButton( icon: const Icon(Icons.close_rounded, color: Color(0xFF64748B), size: 24), onPressed: () => Navigator.pop(sheetContext), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), 14.height, const Divider(height: 1, color: Color(0xFFF1F5F9)), Flexible( child: ListView.separated( shrinkWrap: true, physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), itemCount: offers.length, separatorBuilder: (_, __) => 14.height, itemBuilder: (BuildContext _, int index) { WalaPlusOfferModel offer = offers[index]; return _buildOfferBottomSheetCard(sheetContext, offer, isAr); }, ), ), ], ), ); }, ); } Widget _buildOfferBottomSheetCard(BuildContext sheetContext, WalaPlusOfferModel offer, bool isAr) { String title = offer.title ?? ""; String description = offer.description ?? ""; String type = (offer.type != null && offer.type!.isNotEmpty) ? offer.type! : "discount"; String? expiredAt = offer.expiredAt; bool hasDifferentDescription = description.trim().isNotEmpty && description.trim() != title.trim(); return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))], ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFFE6F7F0), borderRadius: BorderRadius.circular(8)), child: type.toUpperCase().toText10(isBold: true, color: const Color(0xFF00A651)), ), if (expiredAt != null && expiredAt.trim().isNotEmpty && expiredAt != "null") ...[ Row( mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.event_outlined, size: 12, color: Color(0xFF64748B)), const SizedBox(width: 4), expiredAt.toText10(color: const Color(0xFF64748B))], ), ], ], ), 10.height, title.toText14(isBold: true, color: const Color(0xFF1E293B), maxlines: 2), if (hasDifferentDescription) ...[6.height, description.toText12(color: const Color(0xFF64748B), maxLine: 3)], DefaultButton( LocaleKeys.useOffer.tr(), () { Navigator.pop(sheetContext); String offerId = (offer.id != null && offer.id!.isNotEmpty) ? offer.id! : (_retailer?.id ?? _effectiveRetailerId ?? ""); String offerType = (offer.type != null && offer.type!.isNotEmpty) ? offer.type! : (_retailer?.offerType ?? "promo"); _redeemOffer(offerId: offerId, offerType: offerType); }, height: 42, borderRadius: 10, fontSize: 14, ), ], ), ); } @override Widget build(BuildContext context) { bool isAr = Directionality.of(context) == ui.TextDirection.rtl || AppState().isArabic(context); String title = _retailer?.name ?? LocaleKeys.offerDetails.tr(); return Scaffold( backgroundColor: const Color(0xFFF8F9FA), appBar: AppBarWidget( context, title: title, showHomeButton: true, onHomeTapped: () { if (ApiConsts.isDependetLogin) { Navigator.popUntil(context, ModalRoute.withName(AppRoutes.dashboardScreenForDependet)); } else { Navigator.popUntil(context, ModalRoute.withName(AppRoutes.dashboard)); } }, ), body: (_isLoading && !_hasDetailsData) ? Center(child: Image.asset("assets/images/logos/loading_mohemm_logo.gif", height: 96.0, width: 96.0)) : Column( children: [ if (_isLoading && _hasDetailsData) const LinearProgressIndicator(minHeight: 2.5, backgroundColor: Colors.transparent, valueColor: AlwaysStoppedAnimation(Color(0xFF168A89))), Expanded( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 14, offset: const Offset(0, 4))], ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 1. Top Banner Image with rounded corners ClipRRect( borderRadius: BorderRadius.circular(16), child: SizedBox( height: 190, width: double.infinity, child: (_retailer?.displayImage != null && _retailer!.displayImage!.isNotEmpty) ? Image.network( _retailer!.displayImage!, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: const Color(0xFFF3F4F6), child: const Icon(Icons.broken_image, size: 48, color: Color(0xFF9CA3AF))), ) : Container(color: const Color(0xFFF3F4F6), child: const Icon(Icons.image_outlined, size: 48, color: Color(0xFF9CA3AF))), ), ), 14.height, // 2. Brand Strip (Human Resources - Retailer Logo - Dr Sulaiman Al Habib) _buildBrandStrip(isAr), 18.height, // 3. Retailer Name (Centered, Bold) & Offer Title Subtitle (_retailer?.name ?? "").toText22(isBold: true, isCentered: false, color: const Color(0xFF1E293B)), // if (_retailer?.offerTitle != null && _retailer!.offerTitle!.trim().isNotEmpty && _retailer!.offerTitle!.trim() != _retailer!.name?.trim()) ...[ // 6.height, // Center(child: _cleanHtml(_retailer!.offerTitle!).toText16(isCentered: true, color: const Color(0xFF168A89))), // ], 18.height, // 4. 3 Info Badges Row (Store Type, Validity, City) _buildInfoBadgesRow(isAr), 10.height, // 5. Description Section (parsed as HTML) // "${LocaleKeys.description.tr()}:".toText16(isBold: true, color: const Color(0xFF1E293B)), // 8.height, // Html( // data: _getHowToUseHtml(isAr), // style: {"body": Style(fontSize: FontSize(14.0), color: const Color(0xFF4B5563))}, // onLinkTap: (String? url, Map attributes, _) async { // if (url != null && url.isNotEmpty) { // try { // Uri uri = Uri.parse(url); // if (!uri.hasScheme) { // uri = Uri.parse("https://$url"); // } // await launchUrl(uri, mode: LaunchMode.externalApplication); // } catch (e) { // debugPrint("Error launching url: $e"); // } // } // }, // ), ], ), ), // 6. Locations Section (below description) _buildLocationsSection(isAr), // 7. Contact Information (Email & Phone Number) _buildContactSection(isAr), 16.height, ], ), ), ), // 7. Bottom Action Button Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 20), color: Colors.white, child: SizedBox(width: double.infinity, height: 50, child: DefaultButton(LocaleKeys.useOffer.tr(), _isRedeeming ? null : _onUseOffer, height: 50, borderRadius: 12, fontSize: 16)), ), ], ), ); } Widget _buildBrandStrip(bool isAr) { String? logoUrl = _retailer?.displayLogo; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ // Left: Retailer Logo if (logoUrl != null && logoUrl.isNotEmpty) Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.04), blurRadius: 4, offset: const Offset(0, 2))], ), padding: const EdgeInsets.all(2), child: Image.network(logoUrl, height: 36, width: 36, fit: BoxFit.contain, errorBuilder: (_, __, ___) => const SizedBox(width: 36, height: 36)), ) else const SizedBox(width: 36, height: 36), // Right: Mazaya Brand Logo SvgPicture.asset("assets/icons/mazaya_brand.svg", height: 48, fit: BoxFit.contain), ], ); } Widget _buildInfoBadgesRow(bool isAr) { bool isOnline = (_retailer?.type?.toLowerCase() == 'online'); String storeTypeText = isOnline ? LocaleKeys.online.tr() : LocaleKeys.inStore.tr(); IconData storeIcon = isOnline ? Icons.language_rounded : Icons.storefront_rounded; String cityText = _getDisplayCity(isAr); String validityText = _retailer?.getDisplayValidity() ?? LocaleKeys.offerValid.tr(); return Wrap( spacing: 12, runSpacing: 10, children: [ InfoChipWidget( text: storeTypeText, icon: storeIcon, backgroundColor: isOnline ? const Color(0xFFEFF6FF) : const Color(0xFFF5F3FF), textColor: isOnline ? const Color(0xFF2563EB) : const Color(0xFF7C3AED), ), InfoChipWidget(text: validityText, backgroundColor: const Color(0xFFE6F7F0), textColor: const Color(0xFF00A651)), InfoChipWidget( text: cityText, svgIcon: "assets/icons/location.svg", isBold: false, backgroundColor: const Color(0xFFF3F4F6), textColor: const Color(0xFF4B5563), iconColor: const Color(0xFF64748B), iconTextGap: 2.5, ), ], ); } String _getDisplayCity(bool isAr) { if (_retailer?.locations != null && _retailer!.locations!.isNotEmpty) { // 1. First pass: look for a specific city name (e.g. Riyadh, Jeddah, Hail) for (var loc in _retailer!.locations!) { String? cName = isAr ? loc.cityNameAr : loc.cityNameEn; if (cName != null && cName.trim().isNotEmpty && cName != "null") { return cName.trim(); } } // 2. Second pass: fallback to 'city' field if not generic country for (var loc in _retailer!.locations!) { if (loc.city != null && loc.city!.trim().isNotEmpty && loc.city != "null") { String c = loc.city!.trim(); if (c.toLowerCase() != 'saudi arabia' && c != 'المملكة العربية السعودية') { return c; } } } // 3. Third pass: any non-empty city for (var loc in _retailer!.locations!) { if (loc.city != null && loc.city!.trim().isNotEmpty && loc.city != "null") { return loc.city!.trim(); } } } if (_retailer?.type?.toLowerCase() == 'online') { return LocaleKeys.ksa.tr(); } return LocaleKeys.riyadh.tr(); } String _getHowToUseHtml(bool isAr) { String? content; // 1. Language-specific how-to-use field if (isAr) { if (_retailer?.arHowToUse != null && _retailer!.arHowToUse!.trim().isNotEmpty && _retailer!.arHowToUse != "null") { content = _retailer!.arHowToUse!.trim(); } } else { if (_retailer?.enHowToUse != null && _retailer!.enHowToUse!.trim().isNotEmpty && _retailer!.enHowToUse != "null") { content = _retailer!.enHowToUse!.trim(); } } // 2. In WalaPlus API, 'usage' field often holds the rich HTML formatted instructions if (content == null || content.isEmpty) { if (_retailer?.usage != null) { String u = _retailer!.usage.toString().trim(); if (u.isNotEmpty && u != "null") { // If usage contains HTML tags or step keywords, prioritize it if (u.contains('<') || u.toLowerCase().contains('step') || u.contains('خطوة') || u.toLowerCase().contains('discount')) { content = u; } } } } // 3. Fallback to generic 'howToUse' if (content == null || content.isEmpty) { if (_retailer?.howToUse != null && _retailer!.howToUse!.trim().isNotEmpty && _retailer!.howToUse != "null") { content = _retailer!.howToUse!.trim(); } } // 4. Fallback to 'usage' even if plain text if (content == null || content.isEmpty) { if (_retailer?.usage != null) { String u = _retailer!.usage.toString().trim(); if (u.isNotEmpty && u != "null" && int.tryParse(u) == null) { content = u; } } } // 5. Fallback to the other language field if (content == null || content.isEmpty) { if (isAr) { if (_retailer?.enHowToUse != null && _retailer!.enHowToUse!.trim().isNotEmpty && _retailer!.enHowToUse != "null") { content = _retailer!.enHowToUse!.trim(); } } else { if (_retailer?.arHowToUse != null && _retailer!.arHowToUse!.trim().isNotEmpty && _retailer!.arHowToUse != "null") { content = _retailer!.arHowToUse!.trim(); } } } // 6. Fallback to description or info if (content == null || content.isEmpty) { if (_retailer?.description != null && _retailer!.description!.trim().isNotEmpty && _retailer!.description != "null") { content = _retailer!.description!.trim(); } else if (_retailer?.info != null && _retailer!.info!.trim().isNotEmpty && _retailer!.info != "null") { content = _retailer!.info!.trim(); } } if (content == null || content.isEmpty) { return "

${LocaleKeys.noInstructionsAvailable.tr()}

"; } return _formatHtmlInstructions(content); } String _formatHtmlInstructions(String raw) { String s = raw.trim(); // 1. Strip surrounding quotes (handles multiple layers e.g. "\"text\"") while ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { if (s.length >= 2) { s = s.substring(1, s.length - 1).trim(); } else { break; } } // 2. Unescape JSON slashes & quotes s = s.replaceAll(r'\/', '/').replaceAll(r'\"', '"').replaceAll(r"\'", "'"); // 3. Decode \uXXXX Unicode escape sequences s = s.replaceAllMapped(RegExp(r'\\u([0-9a-fA-F]{4})'), (match) { try { int codeUnit = int.parse(match.group(1)!, radix: 16); return String.fromCharCode(codeUnit); } catch (_) { return match.group(0)!; } }); // 4. Decode common specific unicode escapes (quotes, bullets, dashes, etc.) 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', '“'); // Fallback if truncated to 3 digits // 5. Decode HTML entities s = s.replaceAll('"', '"').replaceAll(''', "'").replaceAll(''', "'").replaceAll(' ', ' ').replaceAll('&', '&'); // 6. Check if string contains HTML tags bool hasHtmlTags = RegExp(r'<[a-zA-Z][^>]*>').hasMatch(s); if (!hasHtmlTags) { // Plain text: format nicely into structured HTML with bold step titles and line breaks s = s.replaceAll(r'\r\n', '\n').replaceAll(r'\n', '\n'); // Separate step markers RegExp stepRegex = RegExp( r'(\b(?:First|Second|Third|Fourth|Fifth|Sixth|Seventh|Eighth|Ninth|Tenth)\s+step\s*:?|' r'\b(?:1st|2nd|3rd|4th|5th|6th|7th|8th|9th|10th)\s+step\s*:?|' r'(?:الخطوة\s+(?:الأولى|الثانية|الثالثة|الرابعة|الخامسة|السادسة|السابعة|الثامنة|التاسعة|العاشرة))\s*:?)', caseSensitive: false, ); s = s.replaceAllMapped(stepRegex, (match) { String matchText = match.group(0)!.trim(); return '

$matchText '; }); // Split remaining newlines to
s = s.replaceAll('\n', '
'); // Clean leading
tags s = s.replaceFirst(RegExp(r'^(?:)+'), ''); s = '

$s

'; } return s; } Widget _buildLocationsSection(bool isAr) { List locations = _retailer?.locations ?? []; if (locations.isEmpty) { return const SizedBox.shrink(); } int totalCount = locations.length; List previewLocations = totalCount <= 2 ? locations : locations.take(2).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 18.height, // Header Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ // Container( // padding: const EdgeInsets.all(6), // decoration: BoxDecoration(color: const Color(0xFF168A89).withOpacity(0.1), borderRadius: BorderRadius.circular(8)), // child: const Icon(Icons.storefront_rounded, size: 18, color: Color(0xFF168A89)), // ), 8.width, LocaleKeys.locations.tr().toText16(isBold: false, color: const Color(0xFF202B33)), // 8.width, // Container( // padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), // decoration: BoxDecoration(color: const Color(0xFFF1F5F9), borderRadius: BorderRadius.circular(10)), // child: '$totalCount'.toText11(isBold: true, color: const Color(0xFF64748B)), // ), ], ), if (totalCount > 2) InkWell( onTap: () => _showAllLocationsBottomSheet(locations, isAr), borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ LocaleKeys.showAllLocations.tr(args: ['$totalCount']).toText12(color: const Color(0xFF047857), underlineGap: 1, isUnderLine: true, isBold: false), // const SizedBox(width: 2), // Icon(isAr ? Icons.chevron_left_rounded : Icons.chevron_right_rounded, size: 18, color: const Color(0xFF168A89)), ], ), ), ), ], ), 12.height, // Preview Cards ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, itemCount: previewLocations.length, separatorBuilder: (_, __) => 10.height, itemBuilder: (BuildContext _, int index) { return _buildLocationCard(previewLocations[index], isAr); }, ), // If more than 2, show the banner to browse all // if (totalCount > 2) ...[ // 10.height, // InkWell( // onTap: () => _showAllLocationsBottomSheet(locations, isAr), // borderRadius: BorderRadius.circular(16), // child: Container( // padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), // decoration: BoxDecoration( // color: Colors.white, // borderRadius: BorderRadius.circular(16), // boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))], // ), // child: Row( // children: [ // Container( // padding: const EdgeInsets.all(9), // decoration: BoxDecoration(color: const Color(0xFF168A89).withOpacity(0.08), shape: BoxShape.circle), // child: const Icon(Icons.map_outlined, color: Color(0xFF168A89), size: 18), // ), // 12.width, // Expanded( // child: Column( // crossAxisAlignment: CrossAxisAlignment.start, // children: [ // LocaleKeys.showAllLocations.tr(args: ['$totalCount']).toText14(isBold: true, color: const Color(0xFF168A89)), // 2.height, // LocaleKeys.browseAllBranches.tr(args: ['$totalCount']).toText11(color: const Color(0xFF64748B)), // ], // ), // ), // Icon(isAr ? Icons.arrow_back_ios_rounded : Icons.arrow_forward_ios_rounded, size: 14, color: const Color(0xFF168A89)), // ], // ), // ), // ), // ], ], ); } Widget _buildLocationCard(WalaPlusRetailerLocation loc, bool isAr) { String distanceText = loc.getDisplayDistance() ?? ""; String cityText = loc.getLocalizedCity(isAr); if (cityText.isEmpty) { cityText = loc.city ?? ""; } String addressText = loc.address ?? ""; String primaryTitle = addressText.isNotEmpty ? addressText : cityText; String? secondaryText; if (distanceText.isNotEmpty) { secondaryText = distanceText; } else if (addressText.isNotEmpty && cityText.isNotEmpty && addressText.toLowerCase() != cityText.toLowerCase()) { secondaryText = cityText; } return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Row( // mainAxisAlignment: MainAxisAlignment.spaceBetween, // crossAxisAlignment: CrossAxisAlignment.center, // children: [ // // City Pill Badge // if (cityText.isNotEmpty) // Container( // padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3.5), // decoration: BoxDecoration(color: const Color(0xFF168A89).withOpacity(0.08), borderRadius: BorderRadius.circular(6)), // child: Row( // mainAxisSize: MainAxisSize.min, // children: [const Icon(Icons.location_on, size: 12, color: Color(0xFF168A89)), const SizedBox(width: 4), cityText.toText11(isBold: true, color: const Color(0xFF168A89))], // ), // ) // else // const SizedBox.shrink(), // ], // ), // 10.height, // Branch title & pin icon InkWell( onTap: () => _openMapForLocation(loc), borderRadius: BorderRadius.circular(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( margin: const EdgeInsets.only(top: 2), width: 36, height: 36, alignment: Alignment.center, decoration: const BoxDecoration(color: Color(0xFFF3F4F6), shape: BoxShape.circle), child: SvgPicture.asset("assets/icons/location.svg", width: 16, height: 16), ), 8.width, Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ primaryTitle.toText16(isBold: false, color: const Color(0xFF202B33), maxlines: 2), if (secondaryText != null) ...[2.height, secondaryText.toText12(color: const Color(0xFF057958), maxLine: 1)], ], ), ), ], ), ), 12.width, SvgPicture.asset("assets/icons/location.svg", width: 18, height: 18), // Container( // padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), // decoration: BoxDecoration( // gradient: const LinearGradient( // transform: GradientRotation(.83), // begin: Alignment.topRight, // end: Alignment.bottomLeft, // colors: [MyColors.gradiantEndColor, MyColors.gradiantStartColor], // ), // borderRadius: BorderRadius.circular(8), // ), // child: Row( // mainAxisSize: MainAxisSize.min, // children: [const Icon(Icons.directions_outlined, color: Colors.white, size: 14), const SizedBox(width: 4), LocaleKeys.viewOnMap.tr().toText11(color: Colors.white, isBold: true)], // ), // ), ], ), ), ], ), ); } void _showAllLocationsBottomSheet(List locations, bool isAr) { Map cityCounts = {}; for (var loc in locations) { String c = loc.getLocalizedCity(isAr); if (c.isEmpty) c = loc.city ?? ""; if (c.trim().isNotEmpty) { cityCounts[c.trim()] = (cityCounts[c.trim()] ?? 0) + 1; } } List sortedCities = cityCounts.keys.toList()..sort((a, b) => (cityCounts[b] ?? 0).compareTo(cityCounts[a] ?? 0)); String selectedCity = "ALL"; String searchQuery = ""; TextEditingController searchController = TextEditingController(); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (BuildContext sheetContext) { return StatefulBuilder( builder: (BuildContext _, StateSetter setSheetState) { List filtered = locations.where((loc) { String c = loc.getLocalizedCity(isAr); if (c.isEmpty) c = loc.city ?? ""; if (selectedCity != "ALL" && c.trim() != selectedCity) { return false; } if (searchQuery.isNotEmpty) { String q = searchQuery.toLowerCase(); String addr = (loc.address ?? "").toLowerCase(); String cityEn = (loc.cityNameEn ?? "").toLowerCase(); String cityAr = (loc.cityNameAr ?? "").toLowerCase(); String genericCity = (loc.city ?? "").toLowerCase(); if (!addr.contains(q) && !cityEn.contains(q) && !cityAr.contains(q) && !genericCity.contains(q)) { return false; } } return true; }).toList(); return Container( constraints: BoxConstraints(maxHeight: MediaQuery.of(sheetContext).size.height * 0.85), decoration: const BoxDecoration(color: Color(0xFFF8F9FA), borderRadius: BorderRadius.vertical(top: Radius.circular(24))), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ 12.height, Center(child: Container(width: 44, height: 4.5, decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)))), 12.height, Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ LocaleKeys.locations.tr().toText18(isBold: true, color: const Color(0xFF1E293B)), 3.height, LocaleKeys.branchesCount.tr(args: ['${locations.length}']).toText12(color: const Color(0xFF64748B)), ], ), ), IconButton( icon: const Icon(Icons.close_rounded, color: Color(0xFF64748B), size: 24), onPressed: () => Navigator.pop(sheetContext), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), 12.height, Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( height: 44, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.04), blurRadius: 8, offset: const Offset(0, 2))], ), child: Row( children: [ 12.width, const Icon(Icons.search_rounded, size: 20, color: Color(0xFF94A3B8)), 8.width, Expanded( child: TextField( controller: searchController, style: const TextStyle(fontSize: 13, color: Color(0xFF1E293B)), decoration: InputDecoration( hintText: LocaleKeys.searchBranches.tr(), hintStyle: const TextStyle(fontSize: 13, color: Color(0xFF94A3B8)), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), onChanged: (val) { setSheetState(() { searchQuery = val.trim(); }); }, ), ), if (searchQuery.isNotEmpty) GestureDetector( onTap: () { searchController.clear(); setSheetState(() { searchQuery = ""; }); }, child: const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Icon(Icons.cancel, size: 18, color: Color(0xFF94A3B8))), ), ], ), ), ), if (sortedCities.length > 1) ...[ 10.height, SizedBox( height: 38, child: ListView.separated( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 20), itemCount: sortedCities.length + 1, separatorBuilder: (_, __) => 8.width, itemBuilder: (BuildContext _, int index) { if (index == 0) { bool isSelected = selectedCity == "ALL"; return GestureDetector( onTap: () { setSheetState(() { selectedCity = "ALL"; }); }, 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: '${LocaleKeys.allCities.tr()} (${locations.length})'.toText12(color: isSelected ? MyColors.white : MyColors.black), ), ); } String cName = sortedCities[index - 1]; int cCount = cityCounts[cName] ?? 0; bool isSelected = selectedCity == cName; return GestureDetector( onTap: () { setSheetState(() { selectedCity = cName; }); }, 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: '$cName ($cCount)'.toText12(color: isSelected ? MyColors.white : MyColors.black), ), ); }, ), ), ], 12.height, const Divider(height: 1, color: Color(0xFFF1F5F9)), Flexible( child: filtered.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.location_off_outlined, size: 44, color: Color(0xFF94A3B8)), 12.height, LocaleKeys.noBranchesFound.tr().toText14(isBold: true, color: const Color(0xFF64748B)), ], ), ), ) : ListView.separated( shrinkWrap: true, physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), itemCount: filtered.length, separatorBuilder: (_, __) => 10.height, itemBuilder: (BuildContext _, int index) { return _buildLocationCard(filtered[index], isAr); }, ), ), ], ), ); }, ); }, ); } Future _openMapForLocation(WalaPlusRetailerLocation loc) async { bool hasCoordinates = loc.latitude != null && loc.longitude != null && loc.latitude != 0 && loc.longitude != 0; Uri googleMapsUrl; if (hasCoordinates) { googleMapsUrl = Uri.parse("https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}"); } else { String query = "${loc.address ?? ''} ${loc.city ?? ''}".trim(); if (query.isEmpty) { query = _retailer?.name ?? ""; } googleMapsUrl = Uri.parse("https://www.google.com/maps/search/?api=1&query=${Uri.encodeComponent(query)}"); } try { if (await canLaunchUrl(googleMapsUrl)) { await launchUrl(googleMapsUrl, mode: LaunchMode.externalApplication); } else { await launchUrl(googleMapsUrl); } } catch (e) { debugPrint("Error launching maps: $e"); } } Widget _buildContactSection(bool isAr) { String? email = _retailer?.resolvedEmail; String? phone = _retailer?.resolvedPhone; if ((email == null || email.isEmpty) && (phone == null || phone.isEmpty)) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 18.height, LocaleKeys.contactInfo.tr().toText16(isBold: false, color: const Color(0xFF1E293B)).paddingOnly(right: 8, left: 8), 10.height, if (phone != null && phone.isNotEmpty) ...[ InkWell( onTap: () async { try { Uri phoneUri = Uri(scheme: 'tel', path: phone); await launchUrl(phoneUri); } catch (e) { debugPrint("Error launching phone: $e"); } }, borderRadius: BorderRadius.circular(14), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))], ), child: Row( children: [ Container( width: 36, height: 36, alignment: Alignment.center, decoration: const BoxDecoration(color: Color(0xFFF3F4F6), shape: BoxShape.circle), child: const Icon(Icons.phone_outlined, color: Color(0xFF737373), size: 20), ), 12.width, Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [LocaleKeys.phoneNumber.tr().toText14(color: const Color(0xFF202020)), const SizedBox(height: 2), phone.toText14(color: const Color(0xFF057958))], ), ), // Icon(isAr ? Icons.arrow_back_ios_rounded : Icons.arrow_forward_ios_rounded, size: 13, color: const Color(0xFF94A3B8)), ], ), ), ), if (email != null && email.isNotEmpty) 10.height, ], if (email != null && email.isNotEmpty) ...[ InkWell( onTap: () async { try { Uri emailUri = Uri(scheme: 'mailto', path: email); await launchUrl(emailUri); } catch (e) { debugPrint("Error launching email: $e"); } }, borderRadius: BorderRadius.circular(14), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))], ), child: Row( children: [ Container( width: 36, height: 36, alignment: Alignment.center, decoration: const BoxDecoration(color: Color(0xFFF3F4F6), shape: BoxShape.circle), child: const Icon(Icons.email_outlined, color: Color(0xFF737373), size: 20), ), 12.width, Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [LocaleKeys.email.tr().toText14(color: const Color(0xFF202020)), const SizedBox(height: 2), email.toText14(color: const Color(0xFF057958))], ), ), // Icon(isAr ? Icons.arrow_back_ios_rounded : Icons.arrow_forward_ios_rounded, size: 13, color: const Color(0xFF94A3B8)), ], ), ), ), ], ], ); } String _cleanHtml(String text) { String decoded = WalaPlusRetailerModel.decodeUnicodeString(text) ?? text; return decoded .replaceAll(RegExp(r'', caseSensitive: false), '\n') .replaceAll(RegExp(r'

', caseSensitive: false), '\n\n') .replaceAll(RegExp(r'<[^>]*>'), '') .replaceAll(' ', ' ') .replaceAll('&', '&') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('<', '<') .replaceAll('>', '>') .trim(); } }