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/mazaya/retailer_details_screen.dart

2081 lines
73 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 RetailerDetailsScreen extends StatefulWidget {
final WalaPlusRetailerModel? retailer;
final String? retailerId;
final String? userReference;
const RetailerDetailsScreen({
Key? key,
this.retailer,
this.retailerId,
this.userReference,
}) : super(key: key);
@override
State<RetailerDetailsScreen> createState() => _RetailerDetailsScreenState();
}
class _RetailerDetailsScreenState extends State<RetailerDetailsScreen> {
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;
}
}
// Exclusive offers are mapped straight from the legacy Offers & Discounts API into
// WalaPlusRetailerModel for display - their id isn't a real WalaPlus retailer id, so
// getWalaPlusRetailerById would just fail. Render the already-passed-in data as-is instead.
if (!_hasFetched && _retailer?.isExclusive == true) {
_hasFetched = true;
_isLoading = false;
_hasDetailsData = true;
} else 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 - not shown for exclusive offers (mapped
// from the legacy Offers & Discounts API), since there's no real WalaPlus
// offer/retailer ID behind them for redeemWalaPlusOffer to act on.
if (_retailer?.isExclusive != true)
_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),
),
),
),
// Exclusive offers (mapped from the legacy Offers & Discounts API) have no
// WalaPlus retailer ID, so there's nothing for add/removeWalaPlusFavorite to
// act on - hide the button rather than wiring it to a call that can't work.
if (_retailer?.isExclusive != true)
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 = [];
// Exclusive offers (mapped from the legacy Offers & Discounts API) carry no real
// type/locations/offers data, so there's nothing to infer Online/In Store from - the
// "neither known -> assume In Store" fallback below would otherwise show a wrong chip.
bool isExclusive = _retailer?.isExclusive == true;
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 (!isExclusive && !hasOnline && !hasInstore) {
hasInstore = true;
}
if (isExclusive) {
hasOnline = false;
hasInstore = false;
}
// 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),
),
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),
),
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");
}
}
}