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_...

1586 lines
59 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: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_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/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: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) async {
String title = _retailer?.name ?? LocaleKeys.offerDetails.tr();
await Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => AppInAppWebViewScreen(
url: url,
title: title,
),
),
);
}
void _onUseOffer() {
if (_isRedeeming) return;
List<WalaPlusOfferModel> offers = _retailer?.offers ?? [];
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'];
}
if (transactionUrl != null && transactionUrl.trim().isNotEmpty) {
await _openInAppBrowser(transactionUrl.trim());
} 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
Center(
child: (_retailer?.name ?? "").toText22(
isBold: true,
isCentered: true,
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),
22.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) {
String storeTypeText = (_retailer?.type?.toLowerCase() == 'online')
? LocaleKeys.online.tr()
: LocaleKeys.inStore.tr();
IconData storeIcon = (_retailer?.type?.toLowerCase() == 'online')
? Icons.language_rounded
: Icons.storefront_rounded;
String cityText = _getDisplayCity(isAr);
String validityText = _retailer?.getDisplayValidity() ?? LocaleKeys.offerValid.tr();
return Row(
children: [
Expanded(
child: _buildInfoCard(
icon: storeIcon,
label: storeTypeText,
),
),
12.width,
Expanded(
child: _buildInfoCard(
icon: Icons.check_circle_outline_rounded,
label: validityText,
),
),
12.width,
Expanded(
child: _buildInfoCard(
icon: Icons.location_on_rounded,
label: cityText,
),
),
],
);
}
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();
}
Widget _buildInfoCard({required IconData icon, required String label}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFFF4F6F9),
borderRadius: BorderRadius.circular(14),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: const Color(0xFF168A89), size: 26),
const SizedBox(height: 8),
label.toText12(
isCenter: true,
maxLine: 1,
color: const Color(0xFF334155),
),
],
),
);
}
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: true,
color: const Color(0xFF1E293B),
),
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(0xFF168A89),
isBold: true,
),
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(
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(),
// Directions / Map action button
InkWell(
onTap: () => _openMapForLocation(loc),
borderRadius: BorderRadius.circular(8),
child: 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,
),
],
),
),
),
],
),
10.height,
// Branch title & pin icon
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: const Color(0xFF168A89).withOpacity(0.08),
shape: BoxShape.circle,
),
child: const Icon(Icons.store_mall_directory_rounded, size: 14, color: Color(0xFF168A89)),
),
8.width,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
primaryTitle.toText14(
isBold: true,
color: const Color(0xFF1E293B),
maxlines: 2,
),
if (secondaryText != null) ...[
2.height,
secondaryText.toText12(
color: const Color(0xFF64748B),
maxLine: 1,
),
],
],
),
),
],
),
],
),
);
}
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.contactInformation.tr().toText16(
isBold: true,
color: const Color(0xFF1E293B),
),
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(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF168A89).withOpacity(0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.phone_outlined, color: Color(0xFF168A89), size: 20),
),
12.width,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.phoneNumber.tr().toText12(
color: const Color(0xFF64748B),
),
const SizedBox(height: 2),
phone.toText14(
color: const Color(0xFF1E293B),
),
],
),
),
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(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF168A89).withOpacity(0.12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.email_outlined, color: Color(0xFF168A89), size: 20),
),
12.width,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.email.tr().toText12(
color: const Color(0xFF64748B),
),
const SizedBox(height: 2),
email.toText14(
color: const Color(0xFF1E293B),
),
],
),
),
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();
}
}