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

1377 lines
59 KiB
Dart

2 weeks ago
import 'dart:developer';
2 weeks ago
import 'dart:ui' as ui;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
2 weeks ago
import 'package:flutter_html/flutter_html.dart';
2 weeks ago
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';
2 weeks ago
import 'package:mohem_flutter_app/classes/colors.dart';
import 'package:mohem_flutter_app/classes/consts.dart';
2 weeks 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';
2 weeks ago
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
2 weeks ago
import 'package:mohem_flutter_app/extensions/widget_extensions.dart';
2 weeks ago
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';
2 weeks ago
import 'package:mohem_flutter_app/widgets/app_in_app_webview_screen.dart';
2 weeks ago
import 'package:mohem_flutter_app/widgets/bottom_sheets/confirm_bottom_sheet.dart';
2 weeks ago
import 'package:mohem_flutter_app/widgets/button/default_button.dart';
2 weeks ago
import 'package:mohem_flutter_app/widgets/info_chip_widget.dart';
2 weeks ago
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
class WalaPlusRetailerDetailsScreen extends StatefulWidget {
final WalaPlusRetailerModel? retailer;
final String? retailerId;
2 weeks ago
const WalaPlusRetailerDetailsScreen({Key? key, this.retailer, this.retailerId}) : super(key: key);
@override
State<WalaPlusRetailerDetailsScreen> createState() => _WalaPlusRetailerDetailsScreenState();
}
class _WalaPlusRetailerDetailsScreenState extends State<WalaPlusRetailerDetailsScreen> {
WalaPlusRetailerModel? _retailer;
2 weeks ago
bool _isLoading = true;
bool _hasDetailsData = false;
String? _effectiveRetailerId;
2 weeks 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();
2 weeks ago
if (_effectiveRetailerId == null) {
Object? args = ModalRoute.of(context)?.settings.arguments;
if (args is WalaPlusRetailerModel) {
2 weeks ago
_retailer ??= args;
_effectiveRetailerId = args.id;
} else if (args is String) {
_effectiveRetailerId = args;
}
}
2 weeks ago
if (!_hasFetched && _effectiveRetailerId != null) {
_hasFetched = true;
_fetchRetailerDetails();
}
}
Future<void> _fetchRetailerDetails() async {
2 weeks ago
if (_effectiveRetailerId == null) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
setState(() {
_isLoading = true;
});
try {
2 weeks ago
debugPrint("WalaPlusRetailerDetailsScreen: fetching details for retailer ID: $_effectiveRetailerId");
2 weeks ago
WalaPlusRetailerModel? result = await OffersAndDiscountsApiClient().getWalaPlusRetailerById(retailerId: _effectiveRetailerId!, context: context);
if (result != null && mounted) {
2 weeks 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;
2 weeks ago
_hasDetailsData = true;
});
}
} catch (e) {
debugPrint("Error fetching WalaPlus retailer by ID: $e");
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
2 weeks ago
Future<void> _openInAppBrowser(String url, {String? offerType}) async {
2 weeks ago
String title = _retailer?.name ?? LocaleKeys.offerDetails.tr();
2 weeks ago
bool shouldStartScanning = offerType?.trim().toLowerCase() == "discount";
log("shouldStartScanning: ${shouldStartScanning.toString()} for offerType: ${offerType.toString()}");
2 weeks ago
// Ask for camera permission app-side, before the WebView (and the page's own
// getUserMedia() call) even exists. By the time the page asks, the OS decision is
// already made, so onPermissionRequest can answer instantly - no mid-flight dialog
// racing the page's own timeout for its original getUserMedia() call.
// Discount (QR) offers need the camera to work at all, so if the user denies here,
// don't open the transaction URL - promo offers never reach this check.
if (shouldStartScanning) {
try {
PermissionStatus status = await Permission.camera.status;
log("permission status: ${status.toString()}");
// Permanently denied: Permission.camera.request() won't show a dialog at all in this
// state (Android just returns denied silently) - only the app settings screen can fix it.
if (status.isPermanentlyDenied) {
_showCameraPermissionSheet(LocaleKeys.cameraPermissionPermanentlyDenied.tr());
return;
}
if (!status.isGranted) {
status = await Permission.camera.request();
}
if (status.isPermanentlyDenied) {
_showCameraPermissionSheet(LocaleKeys.cameraPermissionPermanentlyDenied.tr());
return;
}
if (!status.isGranted) {
_showCameraPermissionSheet(LocaleKeys.cameraPermissionRequiredToScan.tr());
return;
}
} catch (e) {
debugPrint("Error requesting camera permission before opening transaction URL: $e");
_showCameraPermissionSheet(LocaleKeys.cameraPermissionRequiredToScan.tr());
return;
}
}
2 weeks ago
await Navigator.push(
context,
2 weeks ago
MaterialPageRoute(builder: (BuildContext context) => AppInAppWebViewScreen(url: url, title: title, onPageLoaded: shouldStartScanning ? _clickStartScanningButton : null)),
);
}
2 weeks ago
// showModalBottomSheet is dismissible by tapping outside by default (isDismissible: true),
// so no extra wiring is needed for that. "Open Settings" is the only action - there's nothing
// useful to retry from here, the user has to leave the app to fix this.
void _showCameraPermissionSheet(String message) {
ConfirmBottomSheet.show(
context,
message: message,
confirmText: LocaleKeys.openSettings.tr(),
showCancelButton: false,
icon: Icons.videocam_off_outlined,
isDestructive: false,
onConfirm: () {
openAppSettings();
},
);
}
2 weeks ago
// Vue's @click handlers are component methods, not something on window, so we find the
// actual button by its text and click it (lets Vue's own handler fire).
// Two buttons matter here: "Start Scanning" (the initial trigger) and "Try Again" (shown
// instead of "Start Scanning" if the camera permission flow takes a while - clicking it
// retries the same flow). Either may need clicking more than once if permission stays
// pending, so instead of a one-shot "clicked" flag we keep watching for as long as the
// safety-net timeout allows, with a short cooldown after each click so a single button
// occurrence can't get double-clicked from back-to-back mutation events before the page
// has a chance to react.
// This can be injected more than once (e.g. once on page load, again once the native camera
// permission result comes back) - state lives on `window`, not in the closure, so a repeat
// call never starts a second observer or spams clicks.
2 weeks ago
// Only called for offerType "discount" - "promo" offers just show a promo code, no scanner.
void _clickStartScanningButton(InAppWebViewController controller) {
controller.evaluateJavascript(
source: r"""
(function() {
2 weeks ago
var cooldownMs = 800;
function tryClick() {
var now = Date.now();
if (now - (window.__mohemmScanLastClickAt || 0) < cooldownMs) return;
2 weeks ago
var buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var text = (buttons[i].textContent || '').replace(/\s+/g, ' ').trim();
2 weeks ago
if (text.indexOf('Start Scanning') !== -1 || text.indexOf('Try Again') !== -1) {
2 weeks ago
buttons[i].click();
2 weeks ago
window.__mohemmScanLastClickAt = now;
2 weeks ago
return;
2 weeks ago
}
2 weeks ago
}
2 weeks ago
}
tryClick();
if (window.__mohemmScanObserverActive) return;
window.__mohemmScanObserverActive = true;
var observer = new MutationObserver(function() {
tryClick();
});
observer.observe(document.body, { childList: true, subtree: true });
// Safety net only - stop watching after a while so we don't observe forever
// (e.g. permission denied for good, or user left the page).
setTimeout(function() {
observer.disconnect();
window.__mohemmScanObserverActive = false;
}, 120000);
2 weeks ago
})();
""",
2 weeks ago
);
}
void _onUseOffer() {
if (_isRedeeming) return;
List<WalaPlusOfferModel> offers = _retailer?.offers ?? [];
2 weeks ago
log("offers.length: ${offers.length}");
2 weeks ago
if (offers.length > 1) {
_showOffersBottomSheet(offers);
} else if (offers.length == 1) {
WalaPlusOfferModel singleOffer = offers.first;
2 weeks ago
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");
2 weeks ago
_redeemOffer(offerId: offerId, offerType: offerType);
} else {
String offerId = _retailer?.id ?? _effectiveRetailerId ?? "";
if (offerId.isEmpty) {
Utils.showToast(LocaleKeys.offerIdNotAvailable.tr());
return;
}
2 weeks ago
_redeemOffer(offerId: offerId, offerType: _retailer?.offerType ?? "promo");
}
2 weeks ago
}
2 weeks ago
Future<void> _redeemOffer({required String offerId, required String offerType}) async {
if (_isRedeeming) return;
if (offerId.isEmpty) {
Utils.showToast(LocaleKeys.offerIdNotAvailable.tr());
return;
}
2 weeks ago
setState(() {
_isRedeeming = true;
});
Utils.showLoading(context);
try {
2 weeks ago
Map<String, dynamic>? res = await OffersAndDiscountsApiClient().redeemWalaPlusOffer(offerId: offerId, offerType: offerType, context: context);
2 weeks ago
Utils.hideLoading(context);
String? transactionUrl;
if (res != null) {
2 weeks ago
transactionUrl = res['Transaction_Url'] ?? res['TransactionUrl'] ?? res['transaction_url'] ?? res['transactionUrl'] ?? res['Url'] ?? res['url'];
2 weeks ago
}
2 weeks ago
log("transactionUrl: ${transactionUrl.toString()}");
2 weeks ago
if (transactionUrl != null && transactionUrl.trim().isNotEmpty) {
2 weeks ago
await _openInAppBrowser(transactionUrl.trim(), offerType: offerType);
2 weeks ago
} else {
2 weeks ago
String? error = res?['Error']?.toString() ?? res?['error']?.toString() ?? res?['ErrorMessage']?.toString() ?? res?['message']?.toString();
2 weeks ago
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;
}
}
}
2 weeks ago
void _showOffersBottomSheet(List<WalaPlusOfferModel> offers) {
bool isAr = Directionality.of(context) == ui.TextDirection.rtl || AppState().isArabic(context);
showModalBottomSheet(
context: context,
2 weeks ago
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext sheetContext) {
return Container(
2 weeks ago
constraints: BoxConstraints(maxHeight: MediaQuery.of(sheetContext).size.height * 0.78),
decoration: const BoxDecoration(color: Color(0xFFF8F9FA), borderRadius: BorderRadius.vertical(top: Radius.circular(24))),
2 weeks ago
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
12.height,
2 weeks ago
Center(child: Container(width: 44, height: 4.5, decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)))),
2 weeks 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: [
2 weeks ago
LocaleKeys.availableOffers.tr().toText18(isBold: true, color: const Color(0xFF1E293B)),
2 weeks ago
4.height,
2 weeks ago
LocaleKeys.selectOfferToRedeem.tr().toText12(color: const Color(0xFF64748B)),
2 weeks ago
],
),
),
2 weeks ago
IconButton(
icon: const Icon(Icons.close_rounded, color: Color(0xFF64748B), size: 24),
onPressed: () => Navigator.pop(sheetContext),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
2 weeks 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);
},
),
),
],
),
2 weeks ago
);
},
);
}
2 weeks 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;
2 weeks ago
bool hasDifferentDescription = description.trim().isNotEmpty && description.trim() != title.trim();
2 weeks ago
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
2 weeks ago
boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))],
),
2 weeks ago
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
2 weeks ago
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
2 weeks ago
decoration: BoxDecoration(color: const Color(0xFFE6F7F0), borderRadius: BorderRadius.circular(8)),
child: type.toUpperCase().toText10(isBold: true, color: const Color(0xFF00A651)),
),
2 weeks ago
if (expiredAt != null && expiredAt.trim().isNotEmpty && expiredAt != "null") ...[
Row(
mainAxisSize: MainAxisSize.min,
2 weeks ago
children: [const Icon(Icons.event_outlined, size: 12, color: Color(0xFF64748B)), const SizedBox(width: 4), expiredAt.toText10(color: const Color(0xFF64748B))],
2 weeks ago
),
],
],
),
2 weeks ago
10.height,
2 weeks ago
title.toText14(isBold: true, color: const Color(0xFF1E293B), maxlines: 2),
if (hasDifferentDescription) ...[6.height, description.toText12(color: const Color(0xFF64748B), maxLine: 3)],
2 weeks ago
DefaultButton(
LocaleKeys.useOffer.tr(),
() {
Navigator.pop(sheetContext);
2 weeks ago
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");
2 weeks ago
_redeemOffer(offerId: offerId, offerType: offerType);
},
height: 42,
borderRadius: 10,
fontSize: 14,
),
],
),
);
}
@override
Widget build(BuildContext context) {
2 weeks 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));
}
},
),
2 weeks ago
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))),
2 weeks ago
),
),
2 weeks ago
14.height,
// 2. Brand Strip (Human Resources - Retailer Logo - Dr Sulaiman Al Habib)
_buildBrandStrip(isAr),
2 weeks ago
18.height,
2 weeks ago
// 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");
// }
// }
// },
// ),
2 weeks ago
],
2 weeks ago
),
),
2 weeks ago
2 weeks ago
// 6. Locations Section (below description)
_buildLocationsSection(isAr),
2 weeks ago
2 weeks ago
// 7. Contact Information (Email & Phone Number)
_buildContactSection(isAr),
2 weeks ago
2 weeks ago
16.height,
],
),
),
),
2 weeks ago
// 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)),
),
2 weeks ago
],
),
);
}
Widget _buildBrandStrip(bool isAr) {
2 weeks ago
String? logoUrl = _retailer?.displayLogo;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
2 weeks ago
children: <Widget>[
// Left: Retailer Logo
if (logoUrl != null && logoUrl.isNotEmpty)
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
2 weeks ago
boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.04), blurRadius: 4, offset: const Offset(0, 2))],
),
2 weeks ago
padding: const EdgeInsets.all(2),
2 weeks ago
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),
2 weeks ago
// Right: Mazaya Brand Logo
2 weeks ago
SvgPicture.asset("assets/icons/mazaya_brand.svg", height: 48, fit: BoxFit.contain),
],
);
}
Widget _buildInfoBadgesRow(bool isAr) {
2 weeks ago
bool isOnline = (_retailer?.type?.toLowerCase() == 'online');
String storeTypeText = isOnline ? LocaleKeys.online.tr() : LocaleKeys.inStore.tr();
2 weeks ago
IconData storeIcon = isOnline ? Icons.language_rounded : Icons.storefront_rounded;
2 weeks ago
String cityText = _getDisplayCity(isAr);
String validityText = _retailer?.getDisplayValidity() ?? LocaleKeys.offerValid.tr();
2 weeks ago
return Wrap(
spacing: 12,
runSpacing: 10,
children: [
2 weeks ago
InfoChipWidget(
text: storeTypeText,
icon: storeIcon,
backgroundColor: isOnline ? const Color(0xFFEFF6FF) : const Color(0xFFF5F3FF),
textColor: isOnline ? const Color(0xFF2563EB) : const Color(0xFF7C3AED),
),
2 weeks ago
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,
),
],
);
}
2 weeks 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();
}
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
2 weeks ago
if (u.contains('<') || u.toLowerCase().contains('step') || u.contains('خطوة') || u.toLowerCase().contains('discount')) {
2 weeks ago
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
2 weeks ago
s = s.replaceAll(r'\/', '/').replaceAll(r'\"', '"').replaceAll(r"\'", "'");
2 weeks ago
// 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
2 weeks ago
s = s.replaceAll('&quot;', '"').replaceAll('&apos;', "'").replaceAll('&#39;', "'").replaceAll('&nbsp;', ' ').replaceAll('&amp;', '&');
2 weeks ago
// 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();
}
2 weeks ago
2 weeks ago
int totalCount = locations.length;
List<WalaPlusRetailerLocation> previewLocations = totalCount <= 2 ? locations : locations.take(2).toList();
2 weeks ago
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
18.height,
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
2 weeks ago
// 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)),
// ),
2 weeks ago
8.width,
2 weeks ago
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)),
// ),
2 weeks ago
],
),
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: [
2 weeks ago
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)),
2 weeks ago
],
),
),
),
],
),
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
2 weeks ago
// 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)),
// ],
// ),
// ),
// ),
// ],
2 weeks ago
],
);
}
2 weeks ago
Widget _buildLocationCard(WalaPlusRetailerLocation loc, bool isAr) {
2 weeks ago
String distanceText = loc.getDisplayDistance() ?? "";
2 weeks ago
String cityText = loc.getLocalizedCity(isAr);
if (cityText.isEmpty) {
cityText = loc.city ?? "";
}
String addressText = loc.address ?? "";
String primaryTitle = addressText.isNotEmpty ? addressText : cityText;
2 weeks ago
String? secondaryText;
if (distanceText.isNotEmpty) {
secondaryText = distanceText;
} else if (addressText.isNotEmpty && cityText.isNotEmpty && addressText.toLowerCase() != cityText.toLowerCase()) {
secondaryText = cityText;
}
2 weeks ago
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
2 weeks ago
boxShadow: <BoxShadow>[BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))],
2 weeks ago
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2 weeks ago
// 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(
2 weeks ago
child: Row(
2 weeks ago
crossAxisAlignment: CrossAxisAlignment.start,
2 weeks ago
children: [
2 weeks ago
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),
2 weeks ago
),
2 weeks ago
8.width,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
2 weeks ago
primaryTitle.toText16(isBold: false, color: const Color(0xFF202B33), maxlines: 2),
2 weeks ago
if (secondaryText != null) ...[2.height, secondaryText.toText12(color: const Color(0xFF057958), maxLine: 1)],
],
),
2 weeks ago
),
],
),
),
2 weeks ago
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)],
// ),
// ),
],
),
2 weeks 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;
}
}
2 weeks ago
List<String> sortedCities = cityCounts.keys.toList()..sort((a, b) => (cityCounts[b] ?? 0).compareTo(cityCounts[a] ?? 0));
2 weeks ago
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) {
2 weeks ago
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();
2 weeks ago
return Container(
2 weeks ago
constraints: BoxConstraints(maxHeight: MediaQuery.of(sheetContext).size.height * 0.85),
decoration: const BoxDecoration(color: Color(0xFFF8F9FA), borderRadius: BorderRadius.vertical(top: Radius.circular(24))),
2 weeks ago
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
12.height,
2 weeks ago
Center(child: Container(width: 44, height: 4.5, decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)))),
2 weeks 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: [
2 weeks ago
LocaleKeys.locations.tr().toText18(isBold: true, color: const Color(0xFF1E293B)),
2 weeks ago
3.height,
2 weeks ago
LocaleKeys.branchesCount.tr(args: ['${locations.length}']).toText12(color: const Color(0xFF64748B)),
2 weeks ago
],
),
),
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,
2 weeks ago
borderRadius: BorderRadius.circular(12),
2 weeks ago
boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.04), blurRadius: 8, offset: const Offset(0, 2))],
2 weeks ago
),
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 = "";
});
},
2 weeks ago
child: const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Icon(Icons.cancel, size: 18, color: Color(0xFF94A3B8))),
2 weeks ago
),
],
),
),
),
2 weeks 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,
2 weeks ago
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),
2 weeks ago
),
);
}
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,
2 weeks ago
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: isSelected ? MyColors.darkIconColor : MyColors.lightGreyEAColor),
child: '$cName ($cCount)'.toText12(color: isSelected ? MyColors.white : MyColors.black),
2 weeks ago
),
);
},
),
),
],
12.height,
const Divider(height: 1, color: Color(0xFFF1F5F9)),
Flexible(
2 weeks ago
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)),
],
),
2 weeks ago
),
2 weeks ago
)
: 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);
},
2 weeks ago
),
),
],
),
2 weeks ago
);
},
);
},
);
}
Future<void> _openMapForLocation(WalaPlusRetailerLocation loc) async {
2 weeks ago
bool hasCoordinates = loc.latitude != null && loc.longitude != null && loc.latitude != 0 && loc.longitude != 0;
2 weeks ago
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,
2 weeks ago
LocaleKeys.contactInfo.tr().toText16(isBold: false, color: const Color(0xFF1E293B)).paddingOnly(right: 8, left: 8),
2 weeks ago
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),
2 weeks ago
boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))],
),
2 weeks ago
child: Row(
children: [
Container(
2 weeks ago
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),
2 weeks ago
),
12.width,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
2 weeks ago
children: [LocaleKeys.phoneNumber.tr().toText14(color: const Color(0xFF202020)), const SizedBox(height: 2), phone.toText14(color: const Color(0xFF057958))],
2 weeks ago
),
),
2 weeks ago
// Icon(isAr ? Icons.arrow_back_ios_rounded : Icons.arrow_forward_ios_rounded, size: 13, color: const Color(0xFF94A3B8)),
2 weeks ago
],
),
2 weeks 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),
2 weeks ago
boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))],
),
2 weeks ago
child: Row(
children: [
Container(
2 weeks ago
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),
2 weeks ago
),
12.width,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
2 weeks ago
children: [LocaleKeys.email.tr().toText14(color: const Color(0xFF202020)), const SizedBox(height: 2), email.toText14(color: const Color(0xFF057958))],
2 weeks ago
),
),
2 weeks ago
// Icon(isAr ? Icons.arrow_back_ios_rounded : Icons.arrow_forward_ios_rounded, size: 13, color: const Color(0xFF94A3B8)),
2 weeks ago
],
),
),
),
2 weeks ago
],
],
);
}
2 weeks 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();
}
}