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

1 month ago
import 'dart:ui' as ui;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
1 month ago
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';
1 month ago
import 'package:mohem_flutter_app/classes/colors.dart';
import 'package:mohem_flutter_app/classes/consts.dart';
1 month ago
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';
1 month ago
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';
1 month ago
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;
1 month ago
bool _isLoading = true;
bool _hasDetailsData = false;
String? _effectiveRetailerId;
1 month ago
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();
1 month ago
if (_effectiveRetailerId == null) {
Object? args = ModalRoute.of(context)?.settings.arguments;
if (args is WalaPlusRetailerModel) {
1 month ago
_retailer ??= args;
_effectiveRetailerId = args.id;
} else if (args is String) {
_effectiveRetailerId = args;
}
}
1 month ago
if (!_hasFetched && _effectiveRetailerId != null) {
_hasFetched = true;
_fetchRetailerDetails();
}
}
Future<void> _fetchRetailerDetails() async {
1 month ago
if (_effectiveRetailerId == null) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
setState(() {
_isLoading = true;
});
try {
1 month ago
debugPrint("WalaPlusRetailerDetailsScreen: fetching details for retailer ID: $_effectiveRetailerId");
WalaPlusRetailerModel? result = await OffersAndDiscountsApiClient().getWalaPlusRetailerById(
retailerId: _effectiveRetailerId!,
context: context,
);
if (result != null && mounted) {
1 month ago
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;
1 month ago
_hasDetailsData = true;
});
}
} catch (e) {
debugPrint("Error fetching WalaPlus retailer by ID: $e");
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
1 month ago
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;
}
1 month ago
_redeemOffer(offerId: offerId, offerType: _retailer?.offerType ?? "promo");
}
1 month ago
}
1 month ago
Future<void> _redeemOffer({required String offerId, required String offerType}) async {
if (_isRedeeming) return;
if (offerId.isEmpty) {
Utils.showToast(LocaleKeys.offerIdNotAvailable.tr());
return;
}
1 month ago
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;
}
}
}
1 month ago
void _showOffersBottomSheet(List<WalaPlusOfferModel> offers) {
bool isAr = Directionality.of(context) == ui.TextDirection.rtl || AppState().isArabic(context);
showModalBottomSheet(
context: context,
1 month ago
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),
),
),
),
1 month ago
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),
),
],
),
),
1 month ago
IconButton(
icon: const Icon(Icons.close_rounded, color: Color(0xFF64748B), size: 24),
onPressed: () => Navigator.pop(sheetContext),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
1 month ago
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);
},
),
),
],
),
1 month ago
);
},
);
}
1 month ago
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;
1 month ago
bool hasDifferentDescription = description.trim().isNotEmpty && description.trim() != title.trim();
1 month ago
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),
),
],
),
1 month ago
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
1 month ago
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),
),
),
1 month ago
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),
),
],
),
],
],
),
1 month ago
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) {
1 month ago
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));
}
},
),
1 month ago
body: (_isLoading && !_hasDetailsData)
? Center(
child: Image.asset(
"assets/images/logos/loading_mohemm_logo.gif",
height: 96.0,
width: 96.0,
),
)
: Column(
children: [
1 month ago
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),
1 month ago
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),
),
1 month ago
],
),
1 month ago
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)),
),
),
),
1 month ago
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");
}
}
},
),
],
),
1 month ago
),
// 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,
1 month ago
child: DefaultButton(
LocaleKeys.useOffer.tr(),
_isRedeeming ? null : _onUseOffer,
height: 50,
borderRadius: 12,
fontSize: 16,
),
),
),
],
),
);
}
Widget _buildBrandStrip(bool isAr) {
1 month ago
String? logoUrl = _retailer?.displayLogo;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
1 month ago
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),
),
],
),
1 month ago
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),
1 month ago
// 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')
1 month ago
? LocaleKeys.online.tr()
: LocaleKeys.inStore.tr();
IconData storeIcon = (_retailer?.type?.toLowerCase() == 'online')
? Icons.language_rounded
: Icons.storefront_rounded;
1 month ago
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,
),
),
],
);
}
1 month ago
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: [
1 month ago
Icon(icon, color: const Color(0xFF168A89), size: 26),
const SizedBox(height: 8),
1 month ago
label.toText12(
isCenter: true,
maxLine: 1,
color: const Color(0xFF334155),
),
],
),
);
}
1 month ago
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();
}
1 month ago
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),
),
],
),
),
),
],
],
);
}
1 month ago
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),
),
1 month ago
],
),
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(
1 month ago
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
1 month ago
color: const Color(0xFF168A89).withOpacity(0.08),
shape: BoxShape.circle,
),
1 month ago
child: const Icon(Icons.store_mall_directory_rounded, size: 14, color: Color(0xFF168A89)),
),
1 month ago
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,
),
],
],
),
),
1 month ago
],
),
],
),
);
}
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(
1 month ago
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,
1 month ago
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)),
),
),
],
),
),
),
1 month ago
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);
},
),
),
],
),
1 month ago
);
},
);
},
);
}
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),
),
],
),
1 month ago
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),
),
],
),
1 month ago
),
),
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),
),
],
),
1 month ago
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),
),
],
),
),
),
1 month ago
],
],
);
}
1 month ago
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();
}
}