You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
mohemm-flutter-app/lib/ui/landing/wala_plus_retailer_details_...

1288 lines
55 KiB
Dart

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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/button/default_button.dart';
import 'package:mohem_flutter_app/widgets/info_chip_widget.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<WalaPlusRetailerDetailsScreen> createState() => _WalaPlusRetailerDetailsScreenState();
}
class _WalaPlusRetailerDetailsScreenState extends State<WalaPlusRetailerDetailsScreen> {
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<void> _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<void> _openInAppBrowser(String url, {String? offerType}) async {
String title = _retailer?.name ?? LocaleKeys.offerDetails.tr();
bool shouldStartScanning = offerType?.trim().toLowerCase() == "discount";
await Navigator.push(
context,
MaterialPageRoute(builder: (BuildContext context) => AppInAppWebViewScreen(url: url, title: title, onPageLoaded: shouldStartScanning ? _clickStartScanningButton : null)),
);
}
// Vue's @click="startScanning()" is a component method, not something on window,
// so we find the actual button by its text and click it (lets Vue's own handler fire).
// Vue may still be mounting when onLoadStop fires, so poll briefly before giving up.
// Only called for offerType "discount" - "promo" offers just show a promo code, no scanner.
void _clickStartScanningButton(InAppWebViewController controller) {
controller.evaluateJavascript(
source: r"""
(function() {
var attempts = 0;
var maxAttempts = 10; // ~0.5s at 50ms
var timer = setInterval(function() {
attempts++;
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) {
buttons[i].click();
clearInterval(timer);
return;
}
}
if (attempts >= maxAttempts) {
clearInterval(timer);
}
}, 50);
})();
""",
);
}
void _onUseOffer() {
if (_isRedeeming) return;
List<WalaPlusOfferModel> 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<void> _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<String, dynamic>? 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<WalaPlusOfferModel> 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>(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<String, String> 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: <Widget>[
// 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 "<p>${LocaleKeys.noInstructionsAvailable.tr()}</p>";
}
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('&quot;', '"').replaceAll('&apos;', "'").replaceAll('&#39;', "'").replaceAll('&nbsp;', ' ').replaceAll('&amp;', '&');
// 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 '<br><br><strong>$matchText</strong> ';
});
// Split remaining newlines to <br>
s = s.replaceAll('\n', '<br>');
// Clean leading <br> tags
s = s.replaceFirst(RegExp(r'^(?:<br\s*/?>)+'), '');
s = '<p>$s</p>';
}
return s;
}
Widget _buildLocationsSection(bool isAr) {
List<WalaPlusRetailerLocation> locations = _retailer?.locations ?? [];
if (locations.isEmpty) {
return const SizedBox.shrink();
}
int totalCount = locations.length;
List<WalaPlusRetailerLocation> 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 cityText = loc.getLocalizedCity(isAr);
if (cityText.isEmpty) {
cityText = loc.city ?? "";
}
String addressText = loc.address ?? "";
String primaryTitle = addressText.isNotEmpty ? addressText : cityText;
String? secondaryText = (addressText.isNotEmpty && cityText.isNotEmpty && addressText.toLowerCase() != cityText.toLowerCase()) ? cityText : null;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: <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<WalaPlusRetailerLocation> locations, bool isAr) {
Map<String, int> 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<String> 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<WalaPlusRetailerLocation> 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<void> _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'<br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</p>', caseSensitive: false), '\n\n')
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.trim();
}
}