You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
mohemm-flutter-app/lib/ui/landing/dashboard_screen_for_depend...

1883 lines
81 KiB
Dart

import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:geolocator/geolocator.dart';
import 'package:mohem_flutter_app/api/login_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/extensions/int_extensions.dart';
import 'package:mohem_flutter_app/extensions/string_extensions.dart';
import 'package:mohem_flutter_app/generated/locale_keys.g.dart';
import 'package:mohem_flutter_app/main.dart';
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_category_model.dart';
import 'package:mohem_flutter_app/models/offers_and_discounts/wala_plus_retailer_model.dart';
import 'package:mohem_flutter_app/models/post_params_model.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:mohem_flutter_app/widgets/location/Location.dart' as location;
import 'package:mohem_flutter_app/widgets/toast/app_toast.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shimmer/shimmer.dart';
class DashboardScreenForDependet extends StatefulWidget {
const DashboardScreenForDependet({Key? key}) : super(key: key);
@override
State<DashboardScreenForDependet> createState() => _DashboardScreenForDependetState();
}
class _DashboardScreenForDependetState extends State<DashboardScreenForDependet> {
List<WalaPlusRetailerModel> retailersList = [];
List<WalaPlusRetailerModel> _filteredRetailersList = [];
List<WalaPlusCategoryModel> categoriesList = [];
String currentCategoryId = "";
String selectedTypeFilter = "all";
// Subcategories of whichever main category is currently selected - fetched via
// getWalaPlusCategories(parentId: ...) whenever a main category card is tapped. Empty/cleared
// when "All Categories" (or nothing) is selected, since there's no specific parent to ask for.
List<WalaPlusCategoryModel> subcategoriesList = <WalaPlusCategoryModel>[];
bool isSubcategoriesLoading = false;
String? _selectedSubcategoryId;
// Filters bottom sheet state - all fully wired to the API now. Availability drives
// WP_Offer_Type (via selectedTypeFilter), _filterSortBy drives WP_Sort_By ('nearest'
// requires WP_Geo_Location, see _ensureLocationReadyForNearestSort), _filterDistance drives
// WP_Radius (null when "any"), _filterMinDiscount drives WP_Min_Discount (null when "any").
// Committed on "Apply filters"/"Reset"; persisted here so reopening the sheet shows the
// last-applied selection (availability's source of truth is selectedTypeFilter itself).
String _filterSortBy = 'nearest';
String _filterDistance = '5km';
String _filterMinDiscount = 'any';
bool isNearestOffers = true;
bool isLoading = true;
bool isLoadingMore = false;
bool isCategoriesLoading = true;
bool hasMore = true;
bool _isFetchingPage = false;
int currentPage = 1;
int totalPages = 1;
final int pageSize = 10;
String searchQuery = "";
Timer? _debounceTimer;
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _hasRegisteredMobileLoginInfo = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
_loadDependentName();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
await _saveDependentSessionToPrefs();
await _registerMobileLoginInfo();
await fetchCategories();
await fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId);
}
});
}
Future<void> _registerMobileLoginInfo() async {
if (_hasRegisteredMobileLoginInfo) return;
if (!ApiConsts.isDependetLogin) return;
_hasRegisteredMobileLoginInfo = true;
try {
int loginType = AppState().dependentLoginType ?? 1;
String nationalId = AppState().getUserName ?? "";
String dob = AppState().dependentDob ?? "";
String name = AppState().dependentName ?? "";
String mobile = AppState().postParamsObject?.mobileNumber ?? "";
String tokenID =
(AppState().postParamsObject?.tokenID != null && AppState().postParamsObject!.tokenID!.isNotEmpty) ? AppState().postParamsObject!.tokenID! : (AppState().authenticationTokenID ?? "");
if (tokenID.isEmpty) {
tokenID = await Utils.getStringFromPrefs("P_DEP_TOKEN_ID");
}
int sessionId = AppState().postParamsObject?.pSessionId ?? 0;
String email = AppState().postParamsObject?.pEmailAddress ?? "";
String deviceToken = AppState().getIsHuawei ? AppState().getHuaweiPushToken : (AppState().getDeviceToken ?? "");
await LoginApiClient().insertMobileLoginInfoDependent(
loginType: loginType,
nationalId: nationalId,
dob: dob,
dependentName: name,
mobileNumber: mobile,
tokenID: tokenID,
sessionId: sessionId,
email: email,
deviceToken: deviceToken,
context: context,
);
} catch (e) {
if (kDebugMode) {
print("Error calling insertMobileLoginInfoDependent: $e");
}
}
}
void _loadDependentName() {
if (AppState().dependentName != null && AppState().dependentName!.isNotEmpty && mounted) {
setState(() {});
}
}
String _cleanDependentName(String name) {
return name.replaceAll(RegExp(r'\s*-\s*'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _scrollListener() {
if (!_scrollController.hasClients) return;
double maxScroll = _scrollController.position.maxScrollExtent;
double currentScroll = _scrollController.position.pixels;
// Trigger only when user scrolls to within 35px of the very bottom
if (currentScroll >= maxScroll - 35) {
if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) {
fetchNextPage();
}
}
}
Future<void> fetchCategories() async {
setState(() {
isCategoriesLoading = true;
});
try {
List<WalaPlusCategoryModel> list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context);
if (list.isEmpty) {
await Future.delayed(const Duration(milliseconds: 600));
list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context);
}
if (mounted) {
List<WalaPlusCategoryModel> fullList = [];
bool hasAll = list.any((c) => (c.id == "all" || c.name?.toLowerCase() == "all" || c.name == "الجميع" || c.name == "الكل"));
if (!hasAll) {
fullList.add(WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr()));
}
fullList.addAll(list);
setState(() {
categoriesList = fullList;
if (currentCategoryId.isEmpty) {
currentCategoryId = "all";
}
});
}
} catch (e) {
debugPrint("Error fetching WalaPlus categories (attempt 1): $e");
try {
await Future.delayed(const Duration(milliseconds: 600));
List<WalaPlusCategoryModel> retryList = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context);
if (mounted && retryList.isNotEmpty) {
List<WalaPlusCategoryModel> fullList = [];
bool hasAll = retryList.any((c) => (c.id == "all" || c.name?.toLowerCase() == "all" || c.name == "الجميع" || c.name == "الكل"));
if (!hasAll) {
fullList.add(WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr()));
}
fullList.addAll(retryList);
setState(() {
categoriesList = fullList;
if (currentCategoryId.isEmpty) {
currentCategoryId = "all";
}
});
return;
}
} catch (retryEx) {
debugPrint("Error fetching WalaPlus categories (attempt 2): $retryEx");
}
if (mounted && categoriesList.isEmpty) {
setState(() {
categoriesList = [WalaPlusCategoryModel(id: "all", name: LocaleKeys.allCategories.tr())];
if (currentCategoryId.isEmpty) {
currentCategoryId = "all";
}
});
}
} finally {
if (mounted) {
setState(() {
isCategoriesLoading = false;
});
}
}
}
// Fetches the subcategories under a main category (WP_ParentId). "All Categories" (or no
// category) has no specific parent, so that just clears the list instead of calling the API.
Future<void> fetchSubcategories(String? parentId) async {
if (parentId == null || parentId.isEmpty || parentId == "all") {
setState(() {
subcategoriesList = <WalaPlusCategoryModel>[];
_selectedSubcategoryId = null;
});
return;
}
setState(() {
isSubcategoriesLoading = true;
});
try {
List<WalaPlusCategoryModel> list = await OffersAndDiscountsApiClient().getWalaPlusCategories(context: context, parentId: parentId);
if (mounted) {
setState(() {
subcategoriesList = list;
_selectedSubcategoryId = null;
});
}
} catch (e) {
debugPrint("Error fetching WalaPlus subcategories: $e");
if (mounted) {
setState(() {
subcategoriesList = <WalaPlusCategoryModel>[];
});
}
} finally {
if (mounted) {
setState(() {
isSubcategoriesLoading = false;
});
}
}
}
Future<void> fetchOffers({String? categoryId, String? query}) async {
String targetCategoryId = categoryId ?? (currentCategoryId.isEmpty ? "all" : currentCategoryId);
if (query != null) {
searchQuery = query;
}
bool wantsNearest = _filterSortBy == 'nearest';
if (wantsNearest && !await _ensureLocationReadyForNearestSort()) {
return;
}
setState(() {
isLoading = true;
currentPage = 1;
hasMore = true;
});
try {
String catParam = (targetCategoryId == "all" || targetCategoryId == "0") ? "" : targetCategoryId;
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
int? radiusParam = _radiusFromFilterDistance(_filterDistance);
int? minDiscountParam = _filterMinDiscount == 'any' ? null : int.tryParse(_filterMinDiscount);
print("Fetching WalaPlus retailers: category='$catParam', offerType='$offerTypeParam', query='$queryParam', page=1, per_page=$pageSize");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: catParam,
page: 1,
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
// Same as getWalaPlusRetailerById: always pass context whenever we have one so
// WP_Geo_Location is attempted - don't gate it on wantsNearest, that only matters for
// whether we block the fetch to prompt for permission first (see the gate above).
context: context,
);
retailersList = response?.retailers ?? <WalaPlusRetailerModel>[];
_filteredRetailersList = retailersList;
currentPage = 1;
totalPages = response?.pagesCount ?? 1;
hasMore = currentPage < totalPages && (response?.retailers?.isNotEmpty ?? false);
} catch (e) {
print("Error fetching WalaPlus retailers: $e");
retailersList = <WalaPlusRetailerModel>[];
_filteredRetailersList = <WalaPlusRetailerModel>[];
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
Future<void> fetchNextPage() async {
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
bool wantsNearest = _filterSortBy == 'nearest';
if (wantsNearest && !await _ensureLocationReadyForNearestSort()) {
return;
}
_isFetchingPage = true;
setState(() {
isLoadingMore = true;
});
try {
int nextPage = currentPage + 1;
String catParam = (currentCategoryId == "all" || currentCategoryId == "0") ? "" : currentCategoryId;
String? offerTypeParam = OffersAndDiscountsApiClient.mapOfferTypeFilter(selectedTypeFilter);
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
int? radiusParam = _radiusFromFilterDistance(_filterDistance);
int? minDiscountParam = (_filterMinDiscount == 'any') ? null : int.tryParse(_filterMinDiscount);
print("Fetching WalaPlus retailers next page: $nextPage of $totalPages, category: '$catParam', offerType: '$offerTypeParam', query: '$queryParam'");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: catParam,
page: nextPage,
perPage: pageSize,
offerType: offerTypeParam,
query: queryParam,
sortBy: _filterSortBy,
radius: radiusParam,
minDiscount: minDiscountParam,
context: context,
);
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? [];
if (newRetailers.isNotEmpty) {
currentPage = nextPage;
totalPages = response?.pagesCount ?? totalPages;
hasMore = currentPage < totalPages;
retailersList.addAll(newRetailers);
_filteredRetailersList = retailersList;
} else {
hasMore = false;
}
} catch (e) {
print("Error fetching next page of WalaPlus retailers: $e");
} finally {
_isFetchingPage = false;
if (mounted) {
setState(() {
isLoadingMore = false;
});
}
}
}
Future<void> _onRefresh() async {
await Future.wait<dynamic>(<Future<void>>[fetchCategories(), fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId, query: _searchController.text)]);
}
// '5km'/'10km'/'25km' -> 5/10/25 for WP_Radius; 'any' -> null (omit the param - no distance cap).
int? _radiusFromFilterDistance(String distance) {
if (distance == 'any') return null;
return int.tryParse(distance.replaceAll('km', ''));
}
// WP_Sort_By=nearest requires WP_Geo_Location to be passed alongside it, so before fetching
// with "Nearest" selected we make sure location services + permission are actually available.
// Mirrors the existing attendance-flow location-permission pattern (see
// mark_attendance_widget.dart's checkHuaweiLocationPermission): services-off -> prompt to open
// settings, permission-not-granted -> request it, still denied -> toast. Never auto-retries the
// fetch - the user must grant access and trigger the fetch again themselves.
Future<bool> _ensureLocationReadyForNearestSort() async {
Completer<bool> enabledCompleter = Completer<bool>();
location.Location.isEnabled((bool isEnabled) {
if (!enabledCompleter.isCompleted) enabledCompleter.complete(isEnabled);
});
bool isEnabled = await enabledCompleter.future;
if (!isEnabled) {
if (mounted) {
ConfirmBottomSheet.show(
context,
message: LocaleKeys.locationServicesRequiredForNearestOffers.tr(),
confirmText: LocaleKeys.openSettings.tr(),
showCancelButton: false,
onConfirm: () async {
await Geolocator.openLocationSettings();
},
);
}
return false;
}
Completer<bool> permissionCompleter = Completer<bool>();
location.Location.havePermission((bool hasPermission) {
if (!permissionCompleter.isCompleted) permissionCompleter.complete(hasPermission);
});
bool hasPermission = await permissionCompleter.future;
if (!hasPermission) {
bool granted = await _requestLocationPermission();
if (!granted) {
AppToast.show(LocaleKeys.locationPermissionRequiredForNearestOffers.tr());
return false;
}
}
return true;
}
Future<bool> _requestLocationPermission() async {
Map<Permission, PermissionStatus> result = await <Permission>[Permission.location].request();
return result[Permission.location] == PermissionStatus.granted || result[Permission.locationAlways] == PermissionStatus.granted;
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
searchQuery = query;
if (query.trim().isEmpty) {
fetchOffers(query: "");
} else {
_debounceTimer = Timer(const Duration(milliseconds: 1000), () {
fetchOffers(query: query.trim());
});
}
}
void _openRetailer(WalaPlusRetailerModel item) async {
await Navigator.pushNamed(context, AppRoutes.walaPlusRetailerDetails, arguments: item);
if (mounted) {
setState(() {});
}
}
Future<void> _saveDependentSessionToPrefs() async {
if (!ApiConsts.isDependetLogin) return;
try {
await Utils.saveStringFromPrefs("P_DEP_IS_LOGGED_IN", "true");
String nationalId = AppState().getUserName ?? "";
if (nationalId.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_NATIONAL_ID", nationalId);
}
String dob = AppState().dependentDob ?? "";
if (dob.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_BIRTH_DATE", dob);
}
String name = AppState().dependentName ?? "";
if (name.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEPENDENT_NAME", name);
}
String mobile = AppState().postParamsObject?.mobileNumber ?? "";
if (mobile.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_MOBILE_NUMBER", mobile);
}
String tokenID =
(AppState().postParamsObject?.tokenID != null && AppState().postParamsObject!.tokenID!.isNotEmpty) ? AppState().postParamsObject!.tokenID! : (AppState().authenticationTokenID ?? "");
if (tokenID.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_TOKEN_ID", tokenID);
}
String logInTokenID = AppState().postParamsObject?.logInTokenID ?? "";
if (logInTokenID.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_LOGIN_TOKEN_ID", logInTokenID);
}
String authTokenID = AppState().authenticationTokenID ?? "";
if (authTokenID.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_AUTHENTICATION_TOKEN_ID", authTokenID);
}
int loginType = AppState().dependentLoginType ?? 1;
await Utils.saveStringFromPrefs("P_DEP_LOGIN_TYPE", loginType.toString());
String deviceToken = AppState().getIsHuawei ? AppState().getHuaweiPushToken : (AppState().getDeviceToken ?? "");
if (deviceToken.isNotEmpty) {
await Utils.saveStringFromPrefs("P_DEP_DEVICE_TOKEN", deviceToken);
}
} catch (e) {
if (kDebugMode) print("Error saving dependent session to prefs: $e");
}
}
Future<void> _clearDependentPrefs() async {
await Utils.removeStringFromPrefs("P_DEP_IS_LOGGED_IN");
await Utils.removeStringFromPrefs("P_DEP_NATIONAL_ID");
await Utils.removeStringFromPrefs("P_DEP_BIRTH_DATE");
await Utils.removeStringFromPrefs("P_DEP_TOKEN_ID");
await Utils.removeStringFromPrefs("P_DEP_LOGIN_TOKEN_ID");
await Utils.removeStringFromPrefs("P_DEP_AUTHENTICATION_TOKEN_ID");
await Utils.removeStringFromPrefs("P_DEP_MOBILE_NUMBER");
await Utils.removeStringFromPrefs("P_DEPENDENT_NAME");
await Utils.removeStringFromPrefs("P_DEP_LOGIN_TYPE");
}
void _performLogout() {
ConfirmBottomSheet.show(
context,
message: LocaleKeys.areYouSureWantToLogOut.tr(),
confirmText: LocaleKeys.yes.tr(),
cancelText: LocaleKeys.no.tr(),
isDestructive: false,
onConfirm: () async {
if (ApiConsts.isDependetLogin) {
await _clearDependentPrefs();
}
ApiConsts.selectedMemberType = MemberType.employee;
ApiConsts.ISDependetLogin = false;
AppState().setDependentName = null;
AppState().setDependentDob = null;
AppState().setDependentLoginType = null;
AppState().setWalaplusDepInfoModel = null;
AppState().setPostParamsInitConfig();
if (mounted) {
Navigator.pushNamedAndRemoveUntil(context, AppRoutes.login, (Route<dynamic> route) => false);
}
},
);
}
Future<void> _changeLanguage(Locale newLocale) async {
if (context.locale == newLocale) return;
await context.setLocale(newLocale);
PostParamsModel? obj = AppState().postParamsObject;
if (obj != null) {
obj.languageID = (newLocale.languageCode == "ar" ? 1 : 2);
AppState().setPostParamsModel(obj);
}
if (mounted) {
setState(() {});
await fetchCategories();
await fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId);
}
}
Widget _buildLanguageIconButton(bool isAr) {
return Tooltip(
message: isAr ? 'English' : 'العربية',
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
if (isAr) {
_changeLanguage(const Locale("en", "US"));
} else {
_changeLanguage(const Locale("ar", "SA"));
}
},
child: const Padding(padding: EdgeInsets.all(3), child: Icon(Icons.language_rounded, size: 24, color: Color(0xFF1E293B))),
),
);
}
@override
Widget build(BuildContext context) {
bool isAr = AppState().isArabic(context);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!ApiConsts.isDependetLogin) ...[
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () {
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
_performLogout();
}
},
child: const Padding(padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Icon(Icons.arrow_back_ios_new_rounded, size: 20, color: Color(0xFF1E293B))),
),
const SizedBox(width: 8),
],
SvgPicture.asset("assets/icons/mazaya_brand.svg", height: 40, fit: BoxFit.contain),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (ApiConsts.isDependetLogin) _buildLanguageIconButton(isAr),
if (!ApiConsts.isDependetLogin) ...[
const SizedBox(width: 4),
Tooltip(
message: LocaleKeys.addDependents.tr(),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.pushNamed(context, AppRoutes.familyMembers, arguments: {'fromMazaya': true});
},
child: Padding(
padding: const EdgeInsets.all(3),
child: SvgPicture.asset('assets/icons/ic_dependents_top.svg', width: 24, height: 24, colorFilter: const ColorFilter.mode(Color(0xFF1E293B), BlendMode.srcIn)),
),
),
),
],
const SizedBox(width: 4),
Tooltip(
message: LocaleKeys.favorite.tr(),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _showFavoritesBottomSheet,
child: const Padding(
padding: EdgeInsets.all(3),
child: Icon(Icons.favorite_rounded, size: 24, color: Colors.black),
),
),
),
if (ApiConsts.isDependetLogin) ...[
const SizedBox(width: 4),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _performLogout,
child: Padding(padding: const EdgeInsets.all(3), child: SvgPicture.asset('assets/images/drawer/logout.svg', height: 20, width: 20)),
),
],
],
),
],
),
),
),
body: SafeArea(
child: RefreshIndicator(
onRefresh: _onRefresh,
color: MyColors.textMixColor,
child: SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Welcome greeting - same "Welcome Back" label + large bold name design used
// on the last-login screen, shown for dependent logins only.
if (ApiConsts.isDependetLogin && (AppState().dependentName ?? "").isNotEmpty) ...[
LocaleKeys.welcomeBack.tr().toText12(),
_cleanDependentName(AppState().dependentName!).toText18(isBold: true),
const SizedBox(height: 16),
],
// Search Input Card
_buildSearchBar(isAr),
// "Browse Categories" Header
_buildBrowseCategoriesHeader(isAr),
// Categories Section
if (categoriesList.isNotEmpty) _buildCategoriesList(isAr) else _buildCategoriesShimmer(),
// Type Filter Chips (All, In Store, Online)
_buildFiltersRow(isAr),
const SizedBox(height: 16),
// Offers Grid
if (isLoading)
_buildRetailersShimmer()
else if (_filteredRetailersList.isNotEmpty) ...[
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
// Fixed pixel height instead of childAspectRatio - the card's content is
// fixed in absolute pixels, not proportional to screen width, so an aspect
// ratio made the computed cell height inconsistent across device sizes.
// _buildOfferCard now also standardizes every card's content height (the
// subtitle line and badges row are always reserved/fixed-height, not
// conditional/wrapping), so 235 is a real, stable fit - not a worst-case
// guess - with a non-scrollable SingleChildScrollView backstop in the card
// itself for the rare case a device still needs a bit more (e.g. large
// system text scaling), which clips instead of overflowing.
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, mainAxisExtent: 235, crossAxisSpacing: 12, mainAxisSpacing: 16),
itemCount: _filteredRetailersList.length,
itemBuilder: (context, index) {
return _buildOfferCard(_filteredRetailersList[index], isAr);
},
),
if (isLoadingMore) ...[
16.height,
Center(child: Padding(padding: const EdgeInsets.symmetric(vertical: 12), child: Image.asset("assets/images/logos/loading_mohemm_logo.gif", height: 48.0, width: 48.0))),
],
] else
Padding(padding: const EdgeInsets.only(top: 40), child: Center(child: LocaleKeys.noOffersAvailable.tr().toText14(color: const Color(0xFF64748B)))),
],
),
),
),
),
);
}
Widget _buildSearchBar(bool isAr) {
return Container(
width: double.infinity,
height: 70,
padding: const EdgeInsets.only(top: 14, right: 16, bottom: 14, left: 16),
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8E8E8), width: 1.0),
boxShadow: [BoxShadow(color: const Color(0xFF000000).withOpacity(0.0314), offset: const Offset(0, 2), blurRadius: 5, spreadRadius: 0)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
LocaleKeys.search.tr().toText12(isBold: true, color: const Color(0xFF2A333C)),
const SizedBox(height: 2),
TextField(
controller: _searchController,
onChanged: _onSearchChanged,
onSubmitted: (String val) {
_debounceTimer?.cancel();
fetchOffers(query: val.trim());
},
textInputAction: TextInputAction.search,
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
hintText: LocaleKeys.searchItems.tr(),
hintStyle: const TextStyle(color: Color(0xFFACAFB1), fontSize: 14, fontWeight: FontWeight.w400, height: 1.2),
border: InputBorder.none,
// Listens to the controller directly (rather than relying on _onSearchChanged's
// own setState elsewhere) so the clear icon reliably shows/hides on every
// keystroke, independent of the search debounce/fetch timing.
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (BuildContext context, TextEditingValue value, Widget? child) {
if (value.text.isEmpty) return const SizedBox.shrink();
return InkWell(
child: const Icon(Icons.clear, size: 18, color: Color(0xFF848486)),
// padding: EdgeInsets.zero,
// constraints: const BoxConstraints(),
onTap: () {
_searchController.clear();
_onSearchChanged("");
},
);
},
),
// max, not min - this search bar's outer Container is a fixed height (70), so the
// icon must never be forced taller than the TextField's own natural height or it
// overflows the available space.
suffixIconConstraints: const BoxConstraints(maxWidth: 24, maxHeight: 28),
),
style: const TextStyle(color: Color(0xFF2A333C), fontSize: 14, fontWeight: FontWeight.w400),
),
],
),
);
}
Widget _buildBrowseCategoriesHeader(bool isAr) {
return Padding(padding: const EdgeInsets.only(top: 22, bottom: 14), child: LocaleKeys.browseCategories.tr().toText18(isBold: true, color: const Color(0xFF1E293B)));
}
Widget _buildCategoriesList(bool isAr) {
return SizedBox(
height: 136.0,
width: double.infinity,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: categoriesList.length,
separatorBuilder: (_, __) => const SizedBox(width: 14),
itemBuilder: (BuildContext context, int index) {
WalaPlusCategoryModel item = categoriesList[index];
bool isSelected = (item.id == "all" && (currentCategoryId == "all" || currentCategoryId.isEmpty)) || (currentCategoryId.isNotEmpty && currentCategoryId == item.id);
String title = (item.id == "all") ? LocaleKeys.allCategories.tr() : (item.name ?? "");
return InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () {
if (currentCategoryId != item.id) {
setState(() {
currentCategoryId = item.id ?? "";
});
fetchOffers(categoryId: currentCategoryId);
fetchSubcategories(currentCategoryId);
}
},
child: SizedBox(
width: 84,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_buildCategoryCard(item, isSelected),
const SizedBox(height: 8),
title.toText13(
// isBold: true,
color: const Color(0xFF1E293B),
maxlines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
),
);
}
// Replaces the old All/In Store/Online chip row: a fixed "Filters" chip (opens the filters
// bottom sheet) + a divider, then a horizontally scrollable row of subcategory chips fetched
// from the API for whichever main category is currently selected (see fetchSubcategories).
Widget _buildFiltersRow(bool isAr) {
// "All Categories" (or nothing selected) has no subcategories to show at all - only a
// specific main category, once it has actually loaded some, gets the divider + chip row.
bool showSubcategoriesRow = currentCategoryId.isNotEmpty && currentCategoryId != "all" && (isSubcategoriesLoading || subcategoriesList.isNotEmpty);
return Padding(
padding: const EdgeInsets.only(top: 14, bottom: 2),
child: SizedBox(
height: 38,
child: Row(
children: <Widget>[
InkWell(
borderRadius: BorderRadius.circular(6),
onTap: _showFiltersBottomSheet,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
alignment: Alignment.center,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: MyColors.lightGreyEAColor),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SvgPicture.asset("assets/icons/filter_icon.svg", width: 14, height: 14, colorFilter: const ColorFilter.mode(MyColors.black, BlendMode.srcIn)),
const SizedBox(width: 5),
LocaleKeys.filters.tr().toText12(color: MyColors.black),
],
),
),
),
if (showSubcategoriesRow) ...<Widget>[
Container(margin: const EdgeInsets.symmetric(horizontal: 10), width: 1, height: 20, color: const Color(0xFFE2E8F0)),
Expanded(
child:
isSubcategoriesLoading
? Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 8),
itemBuilder: (BuildContext _, int __) => Container(width: 70, height: 32, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
// +1 for the leading "All" chip (clears the subcategory selection,
// falling back to just the parent main category).
itemCount: subcategoriesList.length + 1,
separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 8),
itemBuilder: (BuildContext context, int index) {
bool isAllChip = index == 0;
WalaPlusCategoryModel? subcategory = isAllChip ? null : subcategoriesList[index - 1];
bool isSelected = isAllChip ? _selectedSubcategoryId == null : _selectedSubcategoryId == subcategory!.id;
String label = isAllChip ? LocaleKeys.allCategories.tr() : (subcategory!.name ?? "");
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () {
setState(() {
_selectedSubcategoryId = isAllChip ? null : (isSelected ? null : subcategory!.id);
});
// Selecting a subcategory filters offers to just that subcategory;
// "All" (or deselecting) falls back to the parent main category.
fetchOffers(categoryId: _selectedSubcategoryId ?? currentCategoryId);
},
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: label.toText12(color: isSelected ? MyColors.white : MyColors.black),
),
);
},
),
),
],
],
),
),
);
}
// Matches the shared Filters bottom sheet design. All four sections are fully wired to the
// API: Availability -> WP_Offer_Type (via selectedTypeFilter), Sort by -> WP_Sort_By, Distance
// -> WP_Radius, Minimum discount -> WP_Min_Discount. "Any"/"All" maps to null (param omitted).
void _showFiltersBottomSheet() {
String draftAvailability = selectedTypeFilter;
String draftSortBy = _filterSortBy;
String draftDistance = _filterDistance;
String draftMinDiscount = _filterMinDiscount;
const Color darkBg = Color(0xFF202B33);
const Color borderColor = Color(0xFFE2E8F0);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext sheetContext) {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setSheetState) {
Widget buildOption(String label, bool isSelected, VoidCallback onTap, {IconData? icon}) {
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected ? MyColors.lightGreyEAColor : Colors.white,
border: Border.all(color: isSelected ? MyColors.lightGreyEAColor : borderColor),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (icon != null) ...<Widget>[Icon(icon, size: 14, color: isSelected ? MyColors.black : const Color(0xFF64748B)), const SizedBox(width: 5)],
Flexible(child: label.toText13(isBold: true, color: isSelected ? MyColors.black : darkBg, maxlines: 1)),
if (isSelected) ...<Widget>[const SizedBox(width: 5), const Icon(Icons.check, size: 14, color: MyColors.black)],
],
),
),
),
);
}
Widget sectionHeader(String title, {String? hint}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[title.toText14(isBold: true, color: darkBg), if (hint != null) hint.toText12(color: const Color(0xFF94A3B8))],
);
}
return SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24))),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Center(
child: Container(
width: 44,
height: 4.5,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
LocaleKeys.filters.tr().toText20(isBold: true, color: darkBg),
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.pop(sheetContext),
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xFFF1F5F9)),
child: const Icon(Icons.close, size: 18, color: darkBg),
),
),
],
),
20.height,
// Availability - single select, drives the real offerType filter.
LocaleKeys.availability.tr().toText14(isBold: true, color: darkBg),
10.height,
Row(
children: <Widget>[
buildOption(LocaleKeys.allCategories.tr(), draftAvailability == 'all', () => setSheetState(() => draftAvailability = 'all')),
const SizedBox(width: 10),
buildOption(LocaleKeys.online.tr(), draftAvailability == 'online', () => setSheetState(() => draftAvailability = 'online')),
const SizedBox(width: 10),
buildOption(LocaleKeys.inStore.tr(), draftAvailability == 'instore', () => setSheetState(() => draftAvailability = 'instore')),
],
),
20.height,
// Sort by - single select, Nearest and Newest only.
LocaleKeys.sortBy.tr().toText14(isBold: true, color: darkBg),
10.height,
Row(
children: <Widget>[
buildOption(LocaleKeys.nearest.tr(), draftSortBy == 'nearest', () => setSheetState(() => draftSortBy = 'nearest'), icon: Icons.location_on_outlined),
const SizedBox(width: 10),
buildOption(LocaleKeys.newest.tr(), draftSortBy == 'newest', () => setSheetState(() => draftSortBy = 'newest')),
],
),
20.height,
// Distance - single select.
sectionHeader(LocaleKeys.distance.tr(), hint: LocaleKeys.fromYourCurrentLocation.tr()),
10.height,
Row(
children: <Widget>[
buildOption(LocaleKeys.any.tr(), draftDistance == 'any', () => setSheetState(() => draftDistance = 'any')),
const SizedBox(width: 8),
buildOption(LocaleKeys.distanceKm.tr(args: <String>['5']), draftDistance == '5km', () => setSheetState(() => draftDistance = '5km')),
const SizedBox(width: 8),
buildOption(LocaleKeys.distanceKm.tr(args: <String>['10']), draftDistance == '10km', () => setSheetState(() => draftDistance = '10km')),
const SizedBox(width: 8),
buildOption(LocaleKeys.distanceKm.tr(args: <String>['25']), draftDistance == '25km', () => setSheetState(() => draftDistance = '25km')),
],
),
20.height,
// Minimum discount - single select.
LocaleKeys.minimumDiscount.tr().toText14(isBold: true, color: darkBg),
10.height,
Row(
children: <Widget>[
buildOption(LocaleKeys.any.tr(), draftMinDiscount == 'any', () => setSheetState(() => draftMinDiscount = 'any')),
const SizedBox(width: 8),
buildOption(LocaleKeys.discountPercentPlus.tr(args: <String>['10']), draftMinDiscount == '10', () => setSheetState(() => draftMinDiscount = '10')),
const SizedBox(width: 8),
buildOption(LocaleKeys.discountPercentPlus.tr(args: <String>['20']), draftMinDiscount == '20', () => setSheetState(() => draftMinDiscount = '20')),
const SizedBox(width: 8),
buildOption(LocaleKeys.discountPercentPlus.tr(args: <String>['30']), draftMinDiscount == '30', () => setSheetState(() => draftMinDiscount = '30')),
],
),
20.height,
const Divider(height: 1, thickness: 1, color: borderColor),
16.height,
Row(
children: <Widget>[
Expanded(
child: DefaultButton(
LocaleKeys.reset.tr(),
() {
setSheetState(() {
draftAvailability = 'all';
draftSortBy = 'newest';
draftDistance = 'any';
draftMinDiscount = 'any';
});
setState(() {
selectedTypeFilter = draftAvailability;
_filterSortBy = draftSortBy;
_filterDistance = draftDistance;
_filterMinDiscount = draftMinDiscount;
});
Navigator.pop(sheetContext);
fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId);
},
colors: const <Color>[Colors.white, Colors.white],
border: Border.all(color: borderColor),
textColor: darkBg,
),
),
const SizedBox(width: 12),
Expanded(
child: DefaultButton(LocaleKeys.applyFilters.tr(), () {
setState(() {
selectedTypeFilter = draftAvailability;
_filterSortBy = draftSortBy;
_filterDistance = draftDistance;
_filterMinDiscount = draftMinDiscount;
});
Navigator.pop(sheetContext);
fetchOffers(categoryId: currentCategoryId.isEmpty ? "all" : currentCategoryId);
}),
),
],
),
],
),
),
);
},
);
},
);
}
void _showFavoritesBottomSheet() async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
elevation: 0,
builder: (BuildContext sheetContext) {
return const FavoriteMerchantsBottomSheet();
},
);
if (mounted) {
setState(() {});
}
}
Widget _buildCategoryCard(WalaPlusCategoryModel item, bool isSelected) {
const double cardSize = 84.0;
const double borderRadiusVal = 18.0;
const double borderWidthVal = 2.8;
const Color activeBorderColor = Color(0xFF00A86B);
bool isAll = (item.id == "all" || item.id == "0" || (item.name ?? "").toLowerCase() == "all" || item.name == "الجميع" || item.name == "الكل");
Widget imageContent;
if (isAll) {
imageContent = Image.asset('assets/images/cat_all.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _getFallbackCategoryAsset(item));
} else {
String categoryImageUrl = item.categoryImageUrl.isNotEmpty ? item.categoryImageUrl : (item.image?.trim() ?? "");
if (categoryImageUrl.isNotEmpty) {
if (categoryImageUrl.toLowerCase().endsWith('.svg') || item.isSvg) {
imageContent = SvgPicture.network(
categoryImageUrl,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
placeholderBuilder: (_) => const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF00A86B)))),
);
} else {
imageContent = Image.network(categoryImageUrl, width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _getFallbackCategoryAsset(item));
}
} else {
imageContent = _getFallbackCategoryAsset(item);
}
}
return Container(
width: cardSize,
height: cardSize,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(borderRadiusVal),
border: isSelected ? Border.all(color: activeBorderColor, width: borderWidthVal) : null,
),
child: ClipRRect(borderRadius: BorderRadius.circular(isSelected ? (borderRadiusVal - borderWidthVal) : borderRadiusVal), child: imageContent),
);
}
Widget _getFallbackCategoryAsset(WalaPlusCategoryModel item) {
String name = (item.name ?? "").toLowerCase();
if (item.id == "all" || item.id == "0" || name == "all" || name == "الكل" || name == "الجميع") {
return Image.asset('assets/images/cat_all.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
} else if (name.contains("shop") || name.contains("retail") || name.contains("تسوق") || name.contains("تجزئة")) {
return Image.asset('assets/images/cat_retail.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
} else if (name.contains("food") || name.contains("restaurant") || name.contains("مطاعم") || name.contains("أغذية") || name.contains("cafe") || name.contains("مقهى")) {
return Image.asset('assets/images/cat_food.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
} else if (name.contains("fitness") || name.contains("wellness") || name.contains("sport") || name.contains("gym") || name.contains("رياضة") || name.contains("لياقة") || name.contains("صحة")) {
return Image.asset('assets/images/cat_fitness.png', width: double.infinity, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => _buildFallbackCategoryIconWidget(item));
}
return _buildFallbackCategoryIconWidget(item);
}
Widget _buildFallbackCategoryIconWidget(WalaPlusCategoryModel item) {
return Container(color: Colors.transparent, alignment: Alignment.center, child: _getFallbackCategoryIcon(item, const Color(0xFF168A89)));
}
Widget _getFallbackCategoryIcon(WalaPlusCategoryModel item, Color iconColor) {
String name = (item.name ?? "").toLowerCase();
IconData iconData = Icons.grid_view_rounded;
Color color = iconColor;
if (item.id == "all" || name == "all" || name == "الكل" || name == "الجميع") {
iconData = Icons.confirmation_number_outlined;
color = const Color(0xFFE52332);
} else if (name.contains("finance") || name.contains("بنوك") || name.contains("مالي")) {
iconData = Icons.account_balance_rounded;
} else if (name.contains("entertain") || name.contains("activities") || name.contains("أنشطة") || name.contains("ترفيه")) {
iconData = Icons.theater_comedy_rounded;
} else if (name.contains("educat") || name.contains("تعليم")) {
iconData = Icons.menu_book_rounded;
} else if (name.contains("shop") || name.contains("retail") || name.contains("تسوق") || name.contains("تجزئة")) {
iconData = Icons.shopping_bag_outlined;
} else if (name.contains("health") || name.contains("medical") || name.contains("صحة") || name.contains("طبي")) {
iconData = Icons.health_and_safety_outlined;
} else if (name.contains("beauty") || name.contains("جمال") || name.contains("عناية")) {
iconData = Icons.face_retouching_natural_rounded;
} else if (name.contains("fitness") || name.contains("sport") || name.contains("gym") || name.contains("رياضة") || name.contains("لياقة")) {
iconData = Icons.fitness_center_rounded;
} else if (name.contains("hotel") || name.contains("travel") || name.contains("فندق") || name.contains("فنادق") || name.contains("سفر")) {
iconData = Icons.hotel_rounded;
} else if (name.contains("service") || name.contains("خدمات")) {
iconData = Icons.build_circle_outlined;
} else if (name.contains("food") || name.contains("restaurant") || name.contains("مطاعم") || name.contains("أغذية")) {
iconData = Icons.restaurant_rounded;
} else if (name.contains("home") || name.contains("منزل")) {
iconData = Icons.home_rounded;
}
return Icon(iconData, color: color, size: 26);
}
Widget _buildOfferCard(WalaPlusRetailerModel item, bool isAr) {
return WalaPlusRetailerCard(item: item, isAr: isAr, onTap: () => _openRetailer(item));
}
Widget _buildRetailersShimmer() {
return _buildRetailersShimmerWidget(isTablet);
}
Widget _buildCategoriesShimmer() {
return SizedBox(
height: 136.0,
child: Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: ListView.separated(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: 5,
separatorBuilder: (BuildContext _, int __) => const SizedBox(width: 14),
itemBuilder: (BuildContext _, int index) {
return SizedBox(
width: 84,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(width: 84, height: 84, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18))),
const SizedBox(height: 8),
Container(width: 65, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 4),
Container(width: 42, height: 11, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
],
),
);
},
),
),
);
}
}
Widget _buildRetailersShimmerWidget(bool isTablet) {
return Shimmer.fromColors(
baseColor: const Color(0xFFE2E8F0),
highlightColor: const Color(0xFFF8FAFC),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: isTablet ? 4 : 2, mainAxisExtent: 235, crossAxisSpacing: 12, mainAxisSpacing: 16),
itemCount: 4,
itemBuilder: (_, __) {
return Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(height: 120, width: double.infinity, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14))),
const SizedBox(height: 8),
Container(height: 14, width: 110, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 6),
Container(height: 14, width: 80, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4))),
const SizedBox(height: 6),
Row(
children: <Widget>[
Container(height: 28, width: 50, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
const SizedBox(width: 5),
Container(height: 28, width: 45, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(6))),
],
),
],
),
);
},
),
);
}
class WalaPlusRetailerCard extends StatelessWidget {
final WalaPlusRetailerModel item;
final bool isAr;
final VoidCallback onTap;
const WalaPlusRetailerCard({
Key? key,
required this.item,
required this.isAr,
required this.onTap,
}) : super(key: key);
static String getDiscountBadge(WalaPlusRetailerModel item) {
if (item.offerTitle != null) {
RegExp percentRegex = RegExp(r'(\d+%)');
Match? match = percentRegex.firstMatch(item.offerTitle!);
if (match != null) {
return match.group(1)!;
}
}
if (item.name != null) {
RegExp percentRegex = RegExp(r'(\d+%)');
Match? match = percentRegex.firstMatch(item.name!);
if (match != null) {
return match.group(1)!;
}
}
return "";
}
static String getTypeFilterTitle(String type) {
if (type == 'all') {
return LocaleKeys.allCategories.tr();
} else if (type == 'instore') {
return LocaleKeys.inStore.tr();
} else if (type == 'online') {
return LocaleKeys.online.tr();
}
return type.isNotEmpty ? (type[0].toUpperCase() + type.substring(1)) : type;
}
static IconData getTypeFilterIcon(String type) {
if (type == 'instore') {
return Icons.storefront_rounded;
} else if (type == 'online') {
return Icons.language_rounded;
}
return Icons.tune_rounded;
}
static Widget buildMerchantMIcon() {
return Container(color: const Color(0xFFED1C24), alignment: Alignment.center, child: "M".toText10(isBold: true, color: Colors.white));
}
@override
Widget build(BuildContext context) {
String? bannerUrl = (item.imageThumb != null && item.imageThumb!.isNotEmpty) ? item.imageThumb : item.image;
String? logoUrl = (item.logoThumb != null && item.logoThumb!.isNotEmpty) ? item.logoThumb : item.logo;
String discountText = getDiscountBadge(item);
String offerSubtitle = "";
if (discountText.isNotEmpty) {
if (item.offerTitle != null && item.offerTitle!.toLowerCase().contains("off")) {
offerSubtitle = item.offerTitle!;
} else {
offerSubtitle = LocaleKeys.offDiscount.tr(args: [discountText]);
}
} else if (item.offerTitle != null && item.offerTitle!.trim().isNotEmpty) {
offerSubtitle = item.offerTitle!.trim();
}
List<String> offerTypes = item.normalizedTypes;
String usageText = "";
if (item.displayUsage.isNotEmpty) {
usageText = item.displayUsage.replaceAll('usage:', LocaleKeys.usageLabel.tr()).replaceAll('Usage :', LocaleKeys.usageLabel.tr());
}
String cityText = item.getDisplayCity(isAr: isAr);
if (cityText.trim().isEmpty) {
cityText = item.displayCity;
}
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0xFF0D1720).withOpacity(0.0392), offset: const Offset(0, 5), blurRadius: 18, spreadRadius: 0)],
),
padding: const EdgeInsets.all(10),
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
height: 120,
width: double.infinity,
child: Stack(
fit: StackFit.expand,
children: [
(bannerUrl != null && bannerUrl.isNotEmpty)
? Image.network(
bannerUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: const Color(0xFFF1F5F9), child: const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 32)),
)
: Container(color: const Color(0xFFF1F5F9), child: const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 32)),
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 50,
child: Container(
decoration: BoxDecoration(gradient: LinearGradient(begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Colors.black.withOpacity(0.75), Colors.transparent])),
),
),
if (discountText.isNotEmpty)
PositionedDirectional(
top: 20,
end: 0,
child: Container(
padding: const EdgeInsetsDirectional.only(start: 14, end: 10, top: 4, bottom: 4),
decoration: const BoxDecoration(color: Color(0xFFED1C24), borderRadius: BorderRadiusDirectional.only(topStart: Radius.circular(20), bottomStart: Radius.circular(20))),
child: discountText.toText14(isBold: true, color: Colors.white),
),
),
PositionedDirectional(
bottom: 8,
start: 8,
end: 8,
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4)),
clipBehavior: Clip.antiAlias,
child: (logoUrl != null && logoUrl.isNotEmpty) ? Image.network(logoUrl, fit: BoxFit.contain, errorBuilder: (_, __, ___) => buildMerchantMIcon()) : buildMerchantMIcon(),
),
const SizedBox(width: 5),
Expanded(child: (item.name ?? "").toText11(isBold: true, maxLine: 1, color: Colors.white)),
],
),
),
],
),
),
),
const SizedBox(height: 8),
(item.name ?? "").toText14(isBold: true, maxlines: 1, overflow: TextOverflow.ellipsis, color: const Color(0xFF1E293B)),
const SizedBox(height: 2),
offerSubtitle.toText14(maxlines: 1, overflow: TextOverflow.ellipsis, isBold: true, color: const Color(0xFF1E293B)),
const SizedBox(height: 6),
SizedBox(
height: 28,
child: ListView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
children: <Widget>[
for (String offerType in offerTypes) ...<Widget>[
InfoChipWidget(
text: getTypeFilterTitle(offerType),
icon: getTypeFilterIcon(offerType),
backgroundColor: offerType == 'online' ? const Color(0xFFE6F7F0) : const Color(0xFFEFF6FF),
textColor: offerType == 'online' ? const Color(0xFF00A651) : const Color(0xFF2563EB),
borderColor: offerType == 'online' ? const Color(0xFF00A651) : const Color(0xFF3B82F6),
horizontalPadding: 10,
verticalPadding: 5,
borderRadius: 12,
iconSize: 14,
),
const SizedBox(width: 5),
],
if (cityText.trim().isNotEmpty) ...<Widget>[
InfoChipWidget(
text: cityText,
icon: Icons.location_on_outlined,
backgroundColor: const Color(0xFFF8FAFC),
textColor: const Color(0xFF475569),
borderColor: const Color(0xFFCBD5E1),
horizontalPadding: 10,
verticalPadding: 5,
borderRadius: 12,
iconSize: 14,
),
const SizedBox(width: 5),
],
if (usageText.trim().isNotEmpty)
InfoChipWidget(text: usageText, backgroundColor: const Color(0xFFFEF3C7), textColor: const Color(0xFFB45309), horizontalPadding: 10, verticalPadding: 5, borderRadius: 12),
],
),
),
],
),
),
),
);
}
}
class FavoriteMerchantsBottomSheet extends StatefulWidget {
const FavoriteMerchantsBottomSheet({Key? key}) : super(key: key);
@override
State<FavoriteMerchantsBottomSheet> createState() => _FavoriteMerchantsBottomSheetState();
}
class _FavoriteMerchantsBottomSheetState extends State<FavoriteMerchantsBottomSheet> {
List<WalaPlusRetailerModel> favoriteRetailers = [];
bool isLoading = true;
bool isLoadingMore = false;
bool hasMore = true;
bool _isFetchingPage = false;
int currentPage = 1;
int totalPages = 1;
final int pageSize = 10;
String searchQuery = "";
Timer? _debounceTimer;
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_scrollListener);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
fetchFavorites();
}
});
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _scrollListener() {
if (!_scrollController.hasClients) return;
double maxScroll = _scrollController.position.maxScrollExtent;
double currentScroll = _scrollController.position.pixels;
if (currentScroll >= maxScroll - 35) {
if (!isLoading && !isLoadingMore && !_isFetchingPage && hasMore) {
fetchNextPage();
}
}
}
String? _getUserReferenceForFavorites() {
String ref = OffersAndDiscountsApiClient().resolveUserReference();
if (ref.trim().isNotEmpty) return ref.trim();
String? userName = AppState().getUserName;
if (userName != null && userName.trim().isNotEmpty) return userName.trim();
return null;
}
Future<void> fetchFavorites({String? query}) async {
if (query != null) {
searchQuery = query;
}
setState(() {
isLoading = true;
currentPage = 1;
hasMore = true;
});
try {
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
String? userRef = _getUserReferenceForFavorites();
debugPrint("Fetching favorite retailers: query='$queryParam', userReference='$userRef', page=1, per_page=$pageSize");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: "",
page: 1,
perPage: pageSize,
offerType: null,
query: queryParam,
sortBy: null,
radius: null,
minDiscount: null,
context: null,
userReference: userRef,
);
favoriteRetailers = response?.retailers ?? <WalaPlusRetailerModel>[];
for (WalaPlusRetailerModel r in favoriteRetailers) {
r.isFavorite = true;
}
currentPage = 1;
totalPages = response?.pagesCount ?? 1;
hasMore = currentPage < totalPages && (response?.retailers?.isNotEmpty ?? false);
} catch (e) {
debugPrint("Error fetching favorite retailers: $e");
favoriteRetailers = <WalaPlusRetailerModel>[];
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
Future<void> fetchNextPage() async {
if (isLoading || isLoadingMore || _isFetchingPage || !hasMore) return;
_isFetchingPage = true;
setState(() {
isLoadingMore = true;
});
try {
int nextPage = currentPage + 1;
String? queryParam = searchQuery.trim().isNotEmpty ? searchQuery.trim() : null;
String? userRef = _getUserReferenceForFavorites();
debugPrint("Fetching favorite retailers next page: $nextPage of $totalPages, query: '$queryParam', userReference='$userRef'");
WalaPlusRetailersResponse? response = await OffersAndDiscountsApiClient().getWalaPlusRetailers(
categoryId: "",
page: nextPage,
perPage: pageSize,
offerType: null,
query: queryParam,
sortBy: null,
radius: null,
minDiscount: null,
context: null,
userReference: userRef,
);
List<WalaPlusRetailerModel> newRetailers = response?.retailers ?? <WalaPlusRetailerModel>[];
if (newRetailers.isNotEmpty) {
for (WalaPlusRetailerModel r in newRetailers) {
r.isFavorite = true;
}
currentPage = nextPage;
totalPages = response?.pagesCount ?? totalPages;
hasMore = currentPage < totalPages;
favoriteRetailers.addAll(newRetailers);
} else {
hasMore = false;
}
} catch (e) {
debugPrint("Error fetching next page of favorite retailers: $e");
} finally {
_isFetchingPage = false;
if (mounted) {
setState(() {
isLoadingMore = false;
});
}
}
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
searchQuery = query;
if (query.trim().isEmpty) {
fetchFavorites(query: "");
} else {
_debounceTimer = Timer(const Duration(milliseconds: 600), () {
fetchFavorites(query: query.trim());
});
}
}
void _openRetailer(WalaPlusRetailerModel item) async {
await Navigator.pushNamed(context, AppRoutes.walaPlusRetailerDetails, arguments: item);
if (mounted) {
await fetchFavorites(query: _searchController.text);
}
}
@override
Widget build(BuildContext context) {
bool isAr = AppState().isArabic(context);
return Container(
height: MediaQuery.of(context).size.height * 0.94,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 44,
height: 4.5,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(color: const Color(0xFFCBD5E1), borderRadius: BorderRadius.circular(2.5)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => Navigator.pop(context),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Icon(Icons.arrow_back_ios_new_rounded, size: 20, color: Color(0xFF1E293B)),
),
),
const SizedBox(width: 8),
LocaleKeys.favoriteOffers.tr().toText18(weight: FontWeight.w600, color: const Color(0xFF1E293B)),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.favorite_rounded, size: 22, color: Colors.black),
const SizedBox(width: 10),
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.pop(context),
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xFFF1F5F9)),
child: const Icon(Icons.close_rounded, size: 18, color: Color(0xFF1E293B)),
),
),
],
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
width: double.infinity,
height: 70,
padding: const EdgeInsets.only(top: 14, right: 16, bottom: 14, left: 16),
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8E8E8), width: 1.0),
boxShadow: [BoxShadow(color: const Color(0xFF000000).withOpacity(0.0314), offset: const Offset(0, 2), blurRadius: 5, spreadRadius: 0)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
LocaleKeys.search.tr().toText12(isBold: true, color: const Color(0xFF2A333C)),
const SizedBox(height: 2),
TextField(
controller: _searchController,
onChanged: _onSearchChanged,
onSubmitted: (String val) {
_debounceTimer?.cancel();
fetchFavorites(query: val.trim());
},
textInputAction: TextInputAction.search,
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
hintText: LocaleKeys.searchItems.tr(),
hintStyle: const TextStyle(color: Color(0xFFACAFB1), fontSize: 14, fontWeight: FontWeight.w400, height: 1.2),
border: InputBorder.none,
suffixIcon: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (BuildContext context, TextEditingValue value, Widget? child) {
if (value.text.isEmpty) return const SizedBox.shrink();
return InkWell(
child: const Icon(Icons.clear, size: 18, color: Color(0xFF848486)),
onTap: () {
_searchController.clear();
_onSearchChanged("");
},
);
},
),
suffixIconConstraints: const BoxConstraints(maxWidth: 24, maxHeight: 28),
),
style: const TextStyle(color: Color(0xFF2A333C), fontSize: 14, fontWeight: FontWeight.w400),
),
],
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => fetchFavorites(query: _searchController.text),
color: MyColors.textMixColor,
child: isLoading
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _buildRetailersShimmerWidget(isTablet),
)
: favoriteRetailers.isNotEmpty
? SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 4 : 2,
mainAxisExtent: 235,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
),
itemCount: favoriteRetailers.length,
itemBuilder: (context, index) {
return WalaPlusRetailerCard(
item: favoriteRetailers[index],
isAr: isAr,
onTap: () => _openRetailer(favoriteRetailers[index]),
);
},
),
if (isLoadingMore) ...[
16.height,
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Image.asset("assets/images/logos/loading_mohemm_logo.gif", height: 48.0, width: 48.0),
),
),
],
const SizedBox(height: 20),
],
),
)
: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()),
child: Padding(
padding: const EdgeInsets.only(top: 80),
child: Center(
child: LocaleKeys.noOffersAvailable.tr().toText14(color: const Color(0xFF64748B)),
),
),
),
),
),
],
),
),
),
);
}
}