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.
1522 lines
62 KiB
Dart
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");
|
|
}
|
|
}
|
|
}
|