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

1522 lines
62 KiB
Dart

import 'dart:developer';
import 'dart:ui' as ui;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:mohem_flutter_app/api/api_client.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/exceptions/api_exception.dart';
import 'package:mohem_flutter_app/extensions/int_extensions.dart';
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
import 'package:mohem_flutter_app/extensions/widget_extensions.dart';
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
import 'package:mohem_flutter_app/models/generic_response_model.dart';
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart';
import 'package:mohem_flutter_app/widgets/app_bar_widget.dart';
import 'package:mohem_flutter_app/widgets/app_in_app_webview_screen.dart';
import 'package:mohem_flutter_app/widgets/bottom_sheets/confirm_bottom_sheet.dart';
import 'package:mohem_flutter_app/widgets/button/default_button.dart';
import 'package:mohem_flutter_app/widgets/info_chip_widget.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shimmer/shimmer.dart';
import 'package:url_launcher/url_launcher.dart';
class WalaPlusRetailerDetailsScreen extends StatefulWidget {
final WalaPlusRetailerModel? retailer;
final String? retailerId;
final String? userReference;
const WalaPlusRetailerDetailsScreen({Key? key, this.retailer, this.retailerId, this.userReference}) : super(key: key);
@override
State<WalaPlusRetailerDetailsScreen> createState() => _WalaPlusRetailerDetailsScreenState();
}
class _WalaPlusRetailerDetailsScreenState extends State<WalaPlusRetailerDetailsScreen> {
WalaPlusRetailerModel? _retailer;
bool _isLoading = true;
bool _hasDetailsData = false;
String? _effectiveRetailerId;
String? _effectiveUserReference;
bool _hasFetched = false;
bool _isRedeeming = false;
bool _isFavorite = false;
bool _isTogglingFavorite = false;
@override
void initState() {
super.initState();
_retailer = widget.retailer;
_effectiveRetailerId = widget.retailerId ?? widget.retailer?.id;
_effectiveUserReference = widget.userReference;
_isFavorite = widget.retailer?.isFavorite ?? false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_effectiveRetailerId == null) {
Object? args = ModalRoute.of(context)?.settings.arguments;
if (args is WalaPlusRetailerModel) {
_retailer ??= args;
_effectiveRetailerId = args.id;
_isFavorite = args.isFavorite ?? _isFavorite;
} else if (args is String) {
_effectiveRetailerId = args;
} else if (args is Map) {
if (args['retailer'] is WalaPlusRetailerModel) {
_retailer ??= args['retailer'];
_effectiveRetailerId = _retailer?.id;
_isFavorite = _retailer?.isFavorite ?? _isFavorite;
} else if (args['retailerId'] != null) {
_effectiveRetailerId = args['retailerId']?.toString();
}
if (args['userReference'] != null) {
_effectiveUserReference = args['userReference']?.toString();
}
}
}
if (_effectiveUserReference == null || _effectiveUserReference!.isEmpty) {
String resolved = OffersAndDiscountsApiClient().resolveUserReference();
if (resolved.isNotEmpty) {
_effectiveUserReference = resolved;
}
}
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, userReference: _effectiveUserReference);
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.website == null || result.website!.isEmpty || result.website == "null") && _retailer?.website != null) {
result.website = _retailer!.website;
}
if (result.isFavorite == null && _retailer?.isFavorite != null) {
result.isFavorite = _retailer!.isFavorite;
}
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;
}
if ((result.categories == null || result.categories!.isEmpty) && _retailer?.categories != null && _retailer!.categories!.isNotEmpty) {
result.categories = _retailer!.categories;
}
setState(() {
_retailer = result;
_hasDetailsData = true;
_isFavorite = result.isFavorite ?? _isFavorite;
_retailer?.isFavorite = _isFavorite;
});
}
} catch (e) {
debugPrint("Error fetching retailer by ID: $e");
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _openMerchantWebsite() async {
String? web = _retailer?.resolvedWebsite;
if (web == null || web.trim().isEmpty || web == "null") {
Utils.showToast(LocaleKeys.merchantWebsiteNotAvailable.tr());
return;
}
String url = web.trim();
try {
Uri uri = Uri.parse(url);
if (!uri.hasScheme) {
uri = Uri.parse("https://$url");
}
bool launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched) {
await launchUrl(uri);
}
} catch (e) {
debugPrint("Error opening merchant website with external application: $e");
try {
await Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => AppInAppWebViewScreen(url: url.startsWith("http") ? url : "https://$url", title: _retailer?.name ?? LocaleKeys.visitMerchantWebsite.tr()),
),
);
} catch (err) {
debugPrint("Error opening merchant website in webview: $err");
Utils.showToast(url);
}
}
}
Future<void> _toggleFavorite() async {
if (_isTogglingFavorite) return;
String retailerId = _retailer?.id ?? _effectiveRetailerId ?? "";
if (retailerId.isEmpty) {
return;
}
setState(() {
_isTogglingFavorite = true;
});
Utils.showLoading(context);
try {
bool targetFavorite = !_isFavorite;
GenericResponseModel? response;
if (targetFavorite) {
response = await OffersAndDiscountsApiClient().addWalaPlusFavorite(retailerId: retailerId, context: context, userReference: _effectiveUserReference);
} else {
response = await OffersAndDiscountsApiClient().removeWalaPlusFavorite(retailerId: retailerId, context: context, userReference: _effectiveUserReference);
}
Utils.hideLoading(context);
if (response != null && response.errorMessage == null) {
if (mounted) {
setState(() {
_isFavorite = targetFavorite;
_retailer?.isFavorite = targetFavorite;
});
}
bool isAr = Directionality.of(context) == ui.TextDirection.rtl || AppState().isArabic(context);
String? msg;
if (isAr) {
msg = targetFavorite ? LocaleKeys.favoriteAddedSuccess.tr() : LocaleKeys.favoriteRemovedSuccess.tr();
} else {
msg = response.wpFavoritesAddRemoveMessage;
if (msg == null || msg.trim().isEmpty) {
msg = targetFavorite ? LocaleKeys.favoriteAddedSuccess.tr() : LocaleKeys.favoriteRemovedSuccess.tr();
}
}
Utils.showToast(msg, isSuccess: true);
} else {
String error = response?.errorEndUserMessage ?? response?.errorMessage ?? LocaleKeys.favoriteError.tr();
Utils.showToast(error);
}
} catch (e) {
Utils.hideLoading(context);
debugPrint("Error toggling favorite: $e");
String errorMsg = LocaleKeys.favoriteError.tr();
if (e is APIException && e.error?.errorMessage != null && e.error!.errorMessage!.isNotEmpty) {
errorMsg = e.error!.errorMessage!;
}
Utils.showToast(errorMsg);
} finally {
if (mounted) {
setState(() {
_isTogglingFavorite = false;
});
} else {
_isTogglingFavorite = false;
}
}
}
Future<void> _openInAppBrowser(String url, {String? offerType}) async {
String title = _retailer?.name ?? LocaleKeys.offerDetails.tr();
// Offer types seen so far: "promo", "discount", "unique_promo", "promo_unique_qr" (and
// possibly others) - any type that mentions "discount" or "qr" needs the in-page scanner,
// regardless of exact spelling/casing, so check by substring rather than exact match.
String normalizedOfferType = offerType?.trim().toLowerCase() ?? "";
bool shouldStartScanning = normalizedOfferType.contains("discount") || normalizedOfferType.contains("qr");
log("shouldStartScanning: ${shouldStartScanning.toString()} for offerType: ${offerType.toString()}");
// Ask for camera permission app-side, before the WebView (and the page's own
// getUserMedia() call) even exists. By the time the page asks, the OS decision is
// already made, so onPermissionRequest can answer instantly - no mid-flight dialog
// racing the page's own timeout for its original getUserMedia() call.
// QR/discount-style offers need the camera to work at all, so if the user denies here,
// don't open the transaction URL - plain promo offers never reach this check.
if (shouldStartScanning) {
try {
PermissionStatus status = await Permission.camera.status;
log("permission status: ${status.toString()}");
if (status.isPermanentlyDenied) {
_showCameraPermissionSheet(LocaleKeys.cameraPermissionPermanentlyDenied.tr());
return;
}
if (!status.isGranted) {
status = await Permission.camera.request();
}
if (status.isPermanentlyDenied) {
_showCameraPermissionSheet(LocaleKeys.cameraPermissionPermanentlyDenied.tr());
return;
}
if (!status.isGranted) {
_showCameraPermissionSheet(LocaleKeys.cameraPermissionRequiredToScan.tr());
return;
}
} catch (e) {
debugPrint("Error requesting camera permission before opening transaction URL: $e");
_showCameraPermissionSheet(LocaleKeys.cameraPermissionRequiredToScan.tr());
return;
}
}
await Navigator.push(
context,
MaterialPageRoute(builder: (BuildContext context) => AppInAppWebViewScreen(url: url, title: title, onPageLoaded: shouldStartScanning ? _clickStartScanningButton : null)),
);
}
// showModalBottomSheet is dismissible by tapping outside by default (isDismissible: true),
// so no extra wiring is needed for that. "Open Settings" is the only action - there's nothing
// useful to retry from here, the user has to leave the app to fix this.
void _showCameraPermissionSheet(String message) {
ConfirmBottomSheet.show(
context,
message: message,
confirmText: LocaleKeys.openSettings.tr(),
showCancelButton: false,
icon: Icons.videocam_off_outlined,
isDestructive: false,
onConfirm: () {
openAppSettings();
},
);
}
// Vue's @click handlers are component methods, not something on window, so we find the
// actual button by its text and click it (lets Vue's own handler fire).
// Two buttons matter here: "Start Scanning" (the initial trigger) and "Try Again" (shown
// instead of "Start Scanning" if the camera permission flow takes a while - clicking it
// retries the same flow). Either may need clicking more than once if permission stays
// pending, so instead of a one-shot "clicked" flag we keep watching for as long as the
// safety-net timeout allows, with a short cooldown after each click so a single button
// occurrence can't get double-clicked from back-to-back mutation events before the page
// has a chance to react.
// This can be injected more than once (e.g. once on page load, again once the native camera
// permission result comes back) - state lives on `window`, not in the closure, so a repeat
// call never starts a second observer or spams clicks.
// Only called for offer types mentioning "discount" or "qr" - plain promo offers just show a
// promo code, no scanner.
void _clickStartScanningButton(InAppWebViewController controller) {
controller.evaluateJavascript(
source: r"""
(function() {
var cooldownMs = 800;
function tryClick() {
var now = Date.now();
if (now - (window.__mohemmScanLastClickAt || 0) < cooldownMs) return;
var buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var text = (buttons[i].textContent || '').replace(/\s+/g, ' ').trim();
if (text.indexOf('Start Scanning') !== -1 || text.indexOf('Try Again') !== -1) {
buttons[i].click();
window.__mohemmScanLastClickAt = now;
return;
}
}
}
tryClick();
if (window.__mohemmScanObserverActive) return;
window.__mohemmScanObserverActive = true;
var observer = new MutationObserver(function() {
tryClick();
});
observer.observe(document.body, { childList: true, subtree: true });
// Safety net only - stop watching after a while so we don't observe forever
// (e.g. permission denied for good, or user left the page).
setTimeout(function() {
observer.disconnect();
window.__mohemmScanObserverActive = false;
}, 120000);
})();
""",
);
}
void _onUseOffer() {
if (_isRedeeming) return;
List<WalaPlusOfferModel> offers = _retailer?.offers ?? [];
log("offers.length: ${offers.length}");
if (offers.length > 1) {
_showOffersBottomSheet(offers);
} else if (offers.length == 1) {
WalaPlusOfferModel singleOffer = offers.first;
String offerId = (singleOffer.id != null && singleOffer.id!.isNotEmpty) ? singleOffer.id! : (_retailer?.id ?? _effectiveRetailerId ?? "");
String offerType = (singleOffer.type != null && singleOffer.type!.isNotEmpty) ? singleOffer.type! : (_retailer?.offerType ?? "promo");
_redeemOffer(offerId: offerId, offerType: offerType);
} else {
String offerId = _retailer?.id ?? _effectiveRetailerId ?? "";
if (offerId.isEmpty) {
Utils.showToast(LocaleKeys.offerIdNotAvailable.tr());
return;
}
_redeemOffer(offerId: offerId, offerType: _retailer?.offerType ?? "promo");
}
}
Future<void> _redeemOffer({required String offerId, required String offerType}) async {
if (_isRedeeming) return;
if (offerId.isEmpty) {
Utils.showToast(LocaleKeys.offerIdNotAvailable.tr());
return;
}
setState(() {
_isRedeeming = true;
});
Utils.showLoading(context);
try {
Map<String, dynamic>? res = await OffersAndDiscountsApiClient().redeemWalaPlusOffer(offerId: offerId, offerType: offerType, context: context);
Utils.hideLoading(context);
String? transactionUrl;
if (res != null) {
transactionUrl = res['Transaction_Url'] ?? res['TransactionUrl'] ?? res['transaction_url'] ?? res['transactionUrl'] ?? res['Url'] ?? res['url'];
}
log("transactionUrl: ${transactionUrl.toString()}");
if (transactionUrl != null && transactionUrl.trim().isNotEmpty) {
await _openInAppBrowser(transactionUrl.trim(), offerType: offerType);
} else {
String? error = res?['Error']?.toString() ?? res?['error']?.toString() ?? res?['ErrorMessage']?.toString() ?? res?['message']?.toString();
if (error != null && error.isNotEmpty && error != "null") {
Utils.showToast(error);
} else {
Utils.showToast(LocaleKeys.unableToRedeemOffer.tr());
}
}
} catch (e) {
Utils.hideLoading(context);
debugPrint("Error redeeming offer: $e");
Utils.showToast(LocaleKeys.errorRedeemingOffer.tr());
} finally {
if (mounted) {
setState(() {
_isRedeeming = false;
});
} else {
_isRedeeming = false;
}
}
}
void _showOffersBottomSheet(List<WalaPlusOfferModel> offers) {
bool isAr = Directionality.of(context) == ui.TextDirection.rtl || AppState().isArabic(context);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext sheetContext) {
return Container(
constraints: BoxConstraints(maxHeight: MediaQuery.of(sheetContext).size.height * 0.78),
decoration: const BoxDecoration(color: Color(0xFFF8F9FA), borderRadius: BorderRadius.vertical(top: Radius.circular(24))),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
12.height,
Center(child: Container(width: 44, height: 4.5, decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)))),
12.height,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.availableOffers.tr().toText18(isBold: true, color: const Color(0xFF1E293B)),
4.height,
LocaleKeys.selectOfferToRedeem.tr().toText12(color: const Color(0xFF64748B)),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded, color: Color(0xFF64748B), size: 24),
onPressed: () => Navigator.pop(sheetContext),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
14.height,
const Divider(height: 1, color: Color(0xFFF1F5F9)),
Flexible(
child: ListView.separated(
shrinkWrap: true,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
itemCount: offers.length,
separatorBuilder: (_, __) => 14.height,
itemBuilder: (BuildContext _, int index) {
WalaPlusOfferModel offer = offers[index];
return _buildOfferBottomSheetCard(sheetContext, offer, isAr);
},
),
),
],
),
);
},
);
}
Widget _buildOfferBottomSheetCard(BuildContext sheetContext, WalaPlusOfferModel offer, bool isAr) {
String title = offer.title ?? "";
String description = offer.description ?? "";
String type = (offer.type != null && offer.type!.isNotEmpty) ? offer.type! : "discount";
String? expiredAt = offer.expiredAt;
bool hasDifferentDescription = description.trim().isNotEmpty && description.trim() != title.trim();
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xff000000).withOpacity(.05), blurRadius: 10, offset: const Offset(0, 3))],
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: const Color(0xFFE6F7F0), borderRadius: BorderRadius.circular(8)),
child: type.toUpperCase().toText10(isBold: true, color: const Color(0xFF00A651)),
),
if (expiredAt != null && expiredAt.trim().isNotEmpty && expiredAt != "null") ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [const Icon(Icons.event_outlined, size: 12, color: Color(0xFF64748B)), const SizedBox(width: 4), expiredAt.toText10(color: const Color(0xFF64748B))],
),
],
],
),
10.height,
title.toText14(isBold: true, color: const Color(0xFF1E293B), maxlines: 2),
if (hasDifferentDescription) ...[6.height, description.toText12(color: const Color(0xFF64748B), maxLine: 3)],
DefaultButton(
LocaleKeys.useOffer.tr(),
() {
Navigator.pop(sheetContext);
String offerId = (offer.id != null && offer.id!.isNotEmpty) ? offer.id! : (_retailer?.id ?? _effectiveRetailerId ?? "");
String offerType = (offer.type != null && offer.type!.isNotEmpty) ? offer.type! : (_retailer?.offerType ?? "promo");
_redeemOffer(offerId: offerId, offerType: offerType);
},
height: 42,
borderRadius: 10,
fontSize: 14,
),
],
),
);
}
@override
Widget build(BuildContext context) {
bool isAr = Directionality.of(context) == ui.TextDirection.rtl || AppState().isArabic(context);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBarWidget(
context,
title: LocaleKeys.offerDetails.tr(),
showHomeButton: false,
),
body: (_isLoading && !_hasDetailsData)
? _buildDetailsShimmer()
: 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: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Breadcrumbs (Category / Company Name)
_buildBreadcrumbs(isAr),
12.height,
// 2. Hero Card (Top Image with Favorite Heart + Bottom #143F36 Dark Green Container)
_buildHeroCard(isAr),
14.height,
// 3. Store Type & City Filter Chips
_buildChipsRow(isAr),
20.height,
// 4. Merchant Contact (Website, Phone, Email)
_buildMerchantContactSection(isAr),
// 5. Locations Section (Preview Cards + View All)
_buildLocationsSection(isAr),
// 6. Good to Know Section
_buildGoodToKnowSection(isAr),
20.height,
],
),
),
),
// 7. Bottom Redeem Offer Action Bar
_buildBottomActionBar(isAr),
],
),
);
}
// Skeleton mirroring the real card layout (breadcrumbs, hero card, chips, contact, locations)
Widget _buildDetailsShimmer() {
Widget block({double? width, double height = 14, double radius = 4}) {
return Container(width: width, height: height, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(radius)));
}
return Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Breadcrumbs placeholder
block(width: 180, height: 14),
14.height,
// Hero card placeholder
Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
block(width: double.infinity, height: 200, radius: 0),
Container(
width: double.infinity,
color: const Color(0xFFCBD5E1),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
block(width: 160, height: 20),
8.height,
block(width: 220, height: 16),
10.height,
block(width: 120, height: 12),
],
),
),
],
),
),
16.height,
// Chips row placeholder
Row(children: <Widget>[
block(width: 80, height: 32, radius: 20),
const SizedBox(width: 8),
block(width: 80, height: 32, radius: 20),
const SizedBox(width: 8),
block(width: 100, height: 32, radius: 20),
]),
20.height,
// Merchant contact placeholder
block(width: 140, height: 18),
12.height,
block(width: double.infinity, height: 120, radius: 16),
20.height,
// Locations placeholder
block(width: 100, height: 18),
12.height,
block(width: double.infinity, height: 68, radius: 16),
10.height,
block(width: double.infinity, height: 68, radius: 16),
],
),
),
);
}
Widget _buildBreadcrumbs(bool isAr) {
String? categoryName = _retailer?.getDisplayCategory(isAr: isAr, fallbackCategories: OffersAndDiscountsApiClient.cachedCategories);
String retailerName = _retailer?.name ?? '';
return Row(
children: [
if (categoryName != null && categoryName.isNotEmpty) ...[
categoryName.toText13(color: const Color(0xFF0D9488), isBold: true),
" / ".toText13(color: const Color(0xFF94A3B8)),
],
retailerName.toText13(color: const Color(0xFF64748B), isBold: false).expanded,
],
);
}
Widget _buildHeroCard(bool isAr) {
String? categoryName = _retailer?.getDisplayCategory(isAr: isAr, fallbackCategories: OffersAndDiscountsApiClient.cachedCategories);
String displayCity = _retailer?.getDisplayCity(isAr: isAr) ?? '';
String subtitleText = '';
if (categoryName != null && categoryName.isNotEmpty && displayCity.isNotEmpty) {
subtitleText = '$categoryName · $displayCity';
} else if (categoryName != null && categoryName.isNotEmpty) {
subtitleText = categoryName;
} else if (displayCity.isNotEmpty) {
subtitleText = displayCity;
}
String offerTitle = _retailer?.offerTitle ?? (_retailer?.offers != null && _retailer!.offers!.isNotEmpty ? (_retailer!.offers!.first.title ?? '') : '');
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: const Color(0xff000000).withOpacity(.08), blurRadius: 16, offset: const Offset(0, 4)),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top Image Banner with Favorite Heart Button
SizedBox(
height: 200,
width: double.infinity,
child: Stack(
fit: StackFit.expand,
children: [
InkWell(
onTap: _openMerchantWebsite,
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)),
),
),
PositionedDirectional(
top: 14,
end: 14,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.14), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(
_isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
size: 22,
color: _isFavorite ? Colors.black : const Color(0xFF1E293B),
),
tooltip: LocaleKeys.favorite.tr(),
onPressed: _isTogglingFavorite ? null : _toggleFavorite,
),
),
),
],
),
),
// Bottom #143F36 Dark Green Container
Container(
width: double.infinity,
color: const Color(0xFF143F36),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
(_retailer?.name ?? "").toText22(isBold: true, color: Colors.white, isCentered: false),
if (offerTitle.trim().isNotEmpty) ...[
6.height,
offerTitle.trim().toText16(isBold: false, color: Colors.white, isCentered: false, maxlines: 2),
],
if (subtitleText.isNotEmpty) ...[
8.height,
subtitleText.toText12(color: Colors.white.withOpacity(0.85), isCenter: false),
],
],
),
),
],
),
),
);
}
Widget _buildChipsRow(bool isAr) {
List<Widget> chips = [];
List<String> types = _retailer?.normalizedTypes ?? [];
bool hasOnline = types.contains('online') || (_retailer?.isOnlineStore == true);
bool hasInstore = types.contains('instore') || (_retailer?.isInstore == true) || (_retailer?.hasPhysicalLocation == true) || (_retailer?.locations != null && _retailer!.locations!.isNotEmpty);
String rType = (_retailer?.type ?? '').toLowerCase();
String rOfferType = (_retailer?.offerType ?? '').toLowerCase();
if (rType.contains('online') || rOfferType.contains('online')) hasOnline = true;
if (rType.contains('store') || rOfferType.contains('store')) hasInstore = true;
if (rType.isNotEmpty && !rType.contains('online')) hasInstore = true;
if (_retailer?.offers != null) {
for (var offer in _retailer!.offers!) {
String oType = (offer.type ?? '').toLowerCase();
if (oType.contains('online')) hasOnline = true;
if (oType.contains('store') || (oType.isNotEmpty && !oType.contains('online'))) hasInstore = true;
}
}
if (!hasOnline && !hasInstore) {
hasInstore = true;
}
// Online chip
if (hasOnline) {
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFE6F7F0),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFA7F3D0)),
),
child: LocaleKeys.online.tr().toText12(color: const Color(0xFF00A651), isBold: true),
),
);
}
// In Store chip
if (hasInstore) {
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFBFDBFE)),
),
child: LocaleKeys.inStore.tr().toText12(color: const Color(0xFF2563EB), isBold: true),
),
);
}
// Cities chips
List<String> distinctCities = [];
if (_retailer?.locations != null) {
Set<String> seen = {};
for (var loc in _retailer!.locations!) {
String c = loc.getLocalizedCity(isAr);
if (c.isEmpty) c = loc.city ?? '';
if (c.trim().isNotEmpty && !seen.contains(c.trim())) {
seen.add(c.trim());
distinctCities.add(c.trim());
}
}
}
if (distinctCities.length <= 2) {
for (String c in distinctCities) {
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(20),
),
child: c.toText12(color: const Color(0xFF334155), isBold: false),
),
);
}
} else {
for (int i = 0; i < 2; i++) {
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(20),
),
child: distinctCities[i].toText12(color: const Color(0xFF334155), isBold: false),
),
);
}
chips.add(
InkWell(
onTap: () => _showAllLocationsBottomSheet(_retailer!.locations!, isAr),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(20),
),
child: LocaleKeys.moreCitiesCount.tr(args: ['${distinctCities.length - 2}']).toText12(color: const Color(0xFF334155), isBold: false),
),
),
);
}
if (chips.isEmpty) {
return const SizedBox.shrink();
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: chips,
);
}
Widget _buildMerchantContactSection(bool isAr) {
String? website = _retailer?.resolvedWebsite;
String? phone = _retailer?.resolvedPhone;
String? email = _retailer?.resolvedEmail;
bool hasWebsite = website != null && website.trim().isNotEmpty && website != "null";
bool hasPhone = phone != null && phone.trim().isNotEmpty && phone != "null";
bool hasEmail = email != null && email.trim().isNotEmpty && email != "null";
if (!hasWebsite && !hasPhone && !hasEmail) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.merchantContact.tr().toText18(isBold: true, color: const Color(0xFF1E293B)),
12.height,
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(color: const Color(0xff000000).withOpacity(.03), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: Column(
children: [
if (hasWebsite) ...[
InkWell(
onTap: _openMerchantWebsite,
borderRadius: BorderRadius.vertical(top: const Radius.circular(16), bottom: (!hasPhone && !hasEmail) ? const Radius.circular(16) : Radius.zero),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.website.tr().toText12(color: const Color(0xFF64748B)),
4.height,
website.toText14(color: const Color(0xFF2563EB), isUnderLine: true, weight: FontWeight.w500),
],
).expanded,
const Icon(Icons.open_in_new_rounded, size: 20, color: Color(0xFF64748B)),
],
),
),
),
],
if (hasPhone) ...[
if (hasWebsite)
const Divider(height: 1, thickness: 1, color: Color(0xFFF1F5F9)),
InkWell(
onTap: () async {
try {
Uri phoneUri = Uri(scheme: 'tel', path: phone);
await launchUrl(phoneUri);
} catch (e) {
debugPrint("Error launching phone: $e");
}
},
borderRadius: (!hasWebsite && !hasEmail) ? BorderRadius.circular(16) : (!hasEmail ? const BorderRadius.vertical(bottom: Radius.circular(16)) : BorderRadius.zero),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.phone.tr().toText12(color: const Color(0xFF64748B)),
4.height,
phone.toText14(color: const Color(0xFF1E293B), weight: FontWeight.w600),
],
).expanded,
const Icon(Icons.phone_outlined, size: 20, color: Color(0xFF64748B)),
],
),
),
),
],
if (hasEmail) ...[
if (hasWebsite || hasPhone)
const Divider(height: 1, thickness: 1, color: Color(0xFFF1F5F9)),
InkWell(
onTap: () async {
try {
Uri emailUri = Uri(scheme: 'mailto', path: email);
await launchUrl(emailUri);
} catch (e) {
debugPrint("Error launching email: $e");
}
},
borderRadius: BorderRadius.vertical(top: (!hasWebsite && !hasPhone) ? const Radius.circular(16) : Radius.zero, bottom: const Radius.circular(16)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LocaleKeys.email.tr().toText12(color: const Color(0xFF64748B)),
4.height,
email.toText14(color: const Color(0xFF1E293B), weight: FontWeight.w600),
],
).expanded,
const Icon(Icons.mail_outline_rounded, size: 20, color: Color(0xFF64748B)),
],
),
),
),
],
],
),
),
20.height,
],
);
}
Widget _buildGoodToKnowSection(bool isAr) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
10.height,
LocaleKeys.goodToKnow.tr().toText18(isBold: true, color: const Color(0xFF1E293B)),
14.height,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(Icons.info_outline_rounded, color: Color(0xFF0D9488), size: 20),
),
10.width,
LocaleKeys.goodToKnowDesc.tr().toText13(color: const Color(0xFF475569)).expanded,
],
),
16.height,
const Divider(height: 1, thickness: 1, color: Color(0xFFE2E8F0)),
14.height,
InkWell(
onTap: () {
// Terms & conditions link will be provided later
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
LocaleKeys.termsAndConditions.tr().toText14(isBold: true, color: const Color(0xFF1E293B)),
const Icon(Icons.open_in_new_rounded, size: 20, color: Color(0xFF64748B)),
],
),
),
14.height,
const Divider(height: 1, thickness: 1, color: Color(0xFFE2E8F0)),
14.height,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
LocaleKeys.haveAQuestionAboutOffer.tr().toText13(color: const Color(0xFF64748B)),
InkWell(
onTap: () {
// Get help link will be provided later
},
child: LocaleKeys.getHelp.tr().toText13(color: const Color(0xFF0D9488), isBold: true),
),
],
),
],
);
}
Widget _buildBottomActionBar(bool isAr) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
decoration: BoxDecoration(
color: Colors.white,
border: const Border(top: BorderSide(color: Color(0xFFF1F5F9), width: 1)),
boxShadow: [
BoxShadow(color: const Color(0xff000000).withOpacity(.06), blurRadius: 10, offset: const Offset(0, -2)),
],
),
child: SizedBox(
width: double.infinity,
height: 52,
child: Material(
color: _isRedeeming ? const Color(0xFF94A3B8) : const Color(0xFF169E8F),
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: _isRedeeming ? null : _onUseOffer,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _isRedeeming
? const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5, valueColor: AlwaysStoppedAnimation<Color>(Colors.white)),
),
)
: Row(
children: [
LocaleKeys.redeemOffer.tr().toText16(isBold: true, color: Colors.white, isCentered: false),
const Spacer(),
Icon(isAr ? Icons.arrow_back_rounded : Icons.arrow_forward_rounded, color: Colors.white, size: 20),
],
),
),
),
),
),
);
}
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: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
LocaleKeys.locations.tr().toText18(isBold: true, color: const Color(0xFF1E293B)),
InkWell(
onTap: () => _showAllLocationsBottomSheet(locations, isAr),
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
LocaleKeys.viewAll.tr().toText14(color: const Color(0xFF0D9488), isBold: true),
4.width,
Icon(isAr ? Icons.arrow_back_rounded : Icons.arrow_forward_rounded, size: 16, color: const Color(0xFF0D9488)),
],
),
),
),
],
),
12.height,
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);
},
),
16.height,
],
);
}
Widget _buildLocationCard(WalaPlusRetailerLocation loc, bool isAr) {
String distanceText = loc.getDisplayDistance() ?? "";
String cityText = loc.getLocalizedCity(isAr);
if (cityText.isEmpty) {
cityText = loc.city ?? "";
}
String addressText = loc.address ?? "";
String primaryTitle = addressText.isNotEmpty ? addressText : cityText;
String? secondaryText = distanceText.isNotEmpty ? distanceText : (addressText.isNotEmpty ? cityText : null);
return InkWell(
onTap: () => _openMapForLocation(loc),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(color: const Color(0xff000000).withOpacity(.03), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Color(0xFFF1F5F9),
shape: BoxShape.circle,
),
child: const Icon(Icons.location_on_outlined, size: 20, color: Color(0xFF64748B)),
),
12.width,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
primaryTitle.toText16(isBold: true, color: const Color(0xFF1E293B), maxlines: 1),
if (secondaryText != null && secondaryText.isNotEmpty) ...[
3.height,
secondaryText.toText12(color: const Color(0xFF0D9488), weight: FontWeight.w600),
],
],
).expanded,
10.width,
const Icon(Icons.open_in_new_rounded, size: 20, color: Color(0xFF64748B)),
],
),
),
);
}
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");
}
}
}