diff --git a/assets/images/post_card_intro.png b/assets/images/post_card_intro.png index 1712bf0..c1f194c 100644 Binary files a/assets/images/post_card_intro.png and b/assets/images/post_card_intro.png differ diff --git a/lib/buy_a_pass/bloc/buy_pass_bloc.dart b/lib/buy_a_pass/bloc/buy_pass_bloc.dart new file mode 100644 index 0000000..3dc5f2a --- /dev/null +++ b/lib/buy_a_pass/bloc/buy_pass_bloc.dart @@ -0,0 +1,100 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../repository/buy_pass_repository.dart'; +import 'buy_pass_event.dart'; +import 'buy_pass_state.dart'; + +class BuyPassBloc extends Bloc { + final BuyPassRepository repository; + + BuyPassBloc({required this.repository}) : super(BuyPassInitial()) { + /// Handle fetch buy pass data event + on(_onFetchBuyPassData); + + /// Handle change selected card event + on(_onChangeSelectedCard); + + /// Handle update adult count event + on(_onUpdateAdultCount); + + /// Handle update child count event + on(_onUpdateChildCount); + + /// Handle update validity duration event + on(_onUpdateValidityDuration); // ✅ Added + } + + /// Fetch buy pass data from repository + Future _onFetchBuyPassData( + FetchBuyPassData event, + Emitter emit, + ) async { + emit(BuyPassLoading()); + + try { + final data = await repository.fetchBuyPass(); + emit(BuyPassLoaded(data: data)); + } catch (e) { + emit(BuyPassError(e.toString())); + } + } + + /// Change selected card + void _onChangeSelectedCard( + ChangeSelectedCard event, + Emitter emit, + ) { + if (state is BuyPassLoaded) { + final currentState = state as BuyPassLoaded; + final newCard = currentState.data.cards[event.cardIndex]; + + emit(currentState.copyWith( + selectedCardIndex: event.cardIndex, + adultCount: 1, // Reset counts when changing card + childCount: 1, + validityDuration: newCard.minNumber, // ✅ Reset to new card's minNumber + )); + } + } + + /// Update adult count + void _onUpdateAdultCount( + UpdateAdultCount event, + Emitter emit, + ) { + if (state is BuyPassLoaded) { + final currentState = state as BuyPassLoaded; + if (event.count >= 0) { + emit(currentState.copyWith(adultCount: event.count)); + } + } + } + + /// Update child count + void _onUpdateChildCount( + UpdateChildCount event, + Emitter emit, + ) { + if (state is BuyPassLoaded) { + final currentState = state as BuyPassLoaded; + if (event.count >= 0) { + emit(currentState.copyWith(childCount: event.count)); + } + } + } + + /// Update validity duration (days/attractions) + void _onUpdateValidityDuration( + UpdateValidityDuration event, + Emitter emit, + ) { + if (state is BuyPassLoaded) { + final currentState = state as BuyPassLoaded; + final card = currentState.selectedCard; + + // Validate that duration is within min and max range + if (event.duration >= card.minNumber && event.duration <= card.maxNumber) { + emit(currentState.copyWith(validityDuration: event.duration)); + } + } + } +} \ No newline at end of file diff --git a/lib/buy_a_pass/bloc/buy_pass_event.dart b/lib/buy_a_pass/bloc/buy_pass_event.dart new file mode 100644 index 0000000..120edde --- /dev/null +++ b/lib/buy_a_pass/bloc/buy_pass_event.dart @@ -0,0 +1,32 @@ +abstract class BuyPassEvent {} + +/// Event to fetch buy pass data from API +class FetchBuyPassData extends BuyPassEvent {} + +/// Event to change the selected card pass +class ChangeSelectedCard extends BuyPassEvent { + final int cardIndex; + + ChangeSelectedCard(this.cardIndex); +} + +/// Event to update adult count +class UpdateAdultCount extends BuyPassEvent { + final int count; + + UpdateAdultCount(this.count); +} + +/// Event to update child count +class UpdateChildCount extends BuyPassEvent { + final int count; + + UpdateChildCount(this.count); +} + +/// Event to update validity duration (days/attractions) +class UpdateValidityDuration extends BuyPassEvent { + final int duration; + + UpdateValidityDuration(this.duration); +} \ No newline at end of file diff --git a/lib/buy_a_pass/bloc/buy_pass_state.dart b/lib/buy_a_pass/bloc/buy_pass_state.dart new file mode 100644 index 0000000..d122f58 --- /dev/null +++ b/lib/buy_a_pass/bloc/buy_pass_state.dart @@ -0,0 +1,59 @@ +import '../models/buy_pass_model.dart'; + +abstract class BuyPassState {} + +/// Initial state +class BuyPassInitial extends BuyPassState {} + +/// Loading state +class BuyPassLoading extends BuyPassState {} + +/// Success state with data +class BuyPassLoaded extends BuyPassState { + final BuyPassModel data; + final int selectedCardIndex; + final int adultCount; + final int childCount; + final int validityDuration; // ✅ Added + + BuyPassLoaded({ + required this.data, + this.selectedCardIndex = 0, + this.adultCount = 1, + this.childCount = 1, + int? validityDuration, // ✅ Added as optional parameter + }) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber; // ✅ Initialize with minNumber + + /// Method to copy state with updated values + BuyPassLoaded copyWith({ + BuyPassModel? data, + int? selectedCardIndex, + int? adultCount, + int? childCount, + int? validityDuration, // ✅ Added + }) { + return BuyPassLoaded( + data: data ?? this.data, + selectedCardIndex: selectedCardIndex ?? this.selectedCardIndex, + adultCount: adultCount ?? this.adultCount, + childCount: childCount ?? this.childCount, + validityDuration: validityDuration ?? this.validityDuration, // ✅ Added + ); + } + + /// Get currently selected card + CardPass get selectedCard => data.cards[selectedCardIndex]; + + /// Calculate total price + double get totalPrice { + final card = selectedCard; + return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) * validityDuration.toDouble(); // ✅ Multiply by validityDuration + } +} + +/// Error state +class BuyPassError extends BuyPassState { + final String message; + + BuyPassError(this.message); +} \ No newline at end of file diff --git a/lib/buy_a_pass/models/buy_pass_model.dart b/lib/buy_a_pass/models/buy_pass_model.dart new file mode 100644 index 0000000..b6daf98 --- /dev/null +++ b/lib/buy_a_pass/models/buy_pass_model.dart @@ -0,0 +1,304 @@ +import 'dart:convert'; + +/// ---------- MAIN RESPONSE MODEL ---------- +BuyPassModel buyPassModelFromJson(String str) => + BuyPassModel.fromJson(json.decode(str)); + +String buyPassModelToJson(BuyPassModel data) => + json.encode(data.toJson()); + +class BuyPassModel { + final City city; + final List offers; + final List cards; + final List attractions; + + BuyPassModel({ + required this.city, + required this.offers, + required this.cards, + required this.attractions, + }); + + factory BuyPassModel.fromJson(Map json) { + return BuyPassModel( + city: City.fromJson(json['city']), + offers: List.from( + json['offers'].map((x) => Offer.fromJson(x)), + ), + cards: List.from( + json['cards'].map((x) => CardPass.fromJson(x)), + ), + attractions: List.from( + json['attractions'].map((x) => Attraction.fromJson(x)), + ), + ); + } + + Map toJson() => { + "city": city.toJson(), + "offers": offers.map((x) => x.toJson()).toList(), + "cards": cards.map((x) => x.toJson()).toList(), + "attractions": attractions.map((x) => x.toJson()).toList(), + }; +} + +/// ---------- CITY ---------- +class City { + final int id; + final String name; + final String slug; + final String tagLine; + final String description; + final String bestTimeToVisit; + final String priceRange; + final int individualTicketAmount; + final int cityCardTicketAmount; + final HeroBanner heroBanner; + + City({ + required this.id, + required this.name, + required this.slug, + required this.tagLine, + required this.description, + required this.bestTimeToVisit, + required this.priceRange, + required this.individualTicketAmount, + required this.cityCardTicketAmount, + required this.heroBanner, + }); + + factory City.fromJson(Map json) { + return City( + id: json['id'], + name: json['name'], + slug: json['slug'], + tagLine: json['tagLine'], + description: json['description'], + bestTimeToVisit: json['bestTimeToVisit'], + priceRange: json['priceRange'], + individualTicketAmount: json['individualTicketAmount'], + cityCardTicketAmount: json['cityCardTicketAmount'], + heroBanner: HeroBanner.fromJson(json['heroBanner']), + ); + } + + Map toJson() => { + "id": id, + "name": name, + "slug": slug, + "tagLine": tagLine, + "description": description, + "bestTimeToVisit": bestTimeToVisit, + "priceRange": priceRange, + "individualTicketAmount": individualTicketAmount, + "cityCardTicketAmount": cityCardTicketAmount, + "heroBanner": heroBanner.toJson(), + }; +} + +/// ---------- HERO BANNER ---------- +class HeroBanner { + final String title; + final String image; + + HeroBanner({ + required this.title, + required this.image, + }); + + factory HeroBanner.fromJson(Map json) { + return HeroBanner( + title: json['title'], + image: json['image'], + ); + } + + Map toJson() => { + "title": title, + "image": image, + }; +} + +/// ---------- OFFER ---------- +class Offer { + final int id; + final String title; + final String offerCode; + final String? description; // ✅ optional + final String? redemptionLink; // ✅ optional + final String websiteBannerImage; + final String mobileBannerImage; + final String passType; + final DateTime startDateTime; + final DateTime endDateTime; + final String offerStatus; + final bool applyToPasses; + + Offer({ + required this.id, + required this.title, + required this.offerCode, + this.description, + this.redemptionLink, + required this.websiteBannerImage, + required this.mobileBannerImage, + required this.passType, + required this.startDateTime, + required this.endDateTime, + required this.offerStatus, + required this.applyToPasses, + }); + + factory Offer.fromJson(Map json) { + return Offer( + id: json['id'], + title: json['title'], + offerCode: json['offerCode'], + description: json['description'], // ✅ + redemptionLink: json['redemptionLink'], // ✅ + websiteBannerImage: json['websiteBannerImage'], + mobileBannerImage: json['mobileBannerImage'], + passType: json['passType'], + startDateTime: DateTime.parse(json['startDateTime']), + endDateTime: DateTime.parse(json['endDateTime']), + offerStatus: json['offerStatus'], + applyToPasses: json['applyToPasses'], + ); + } + + Map toJson() => { + "id": id, + "title": title, + "offerCode": offerCode, + "description": description, + "redemptionLink": redemptionLink, + "websiteBannerImage": websiteBannerImage, + "mobileBannerImage": mobileBannerImage, + "passType": passType, + "startDateTime": startDateTime.toIso8601String(), + "endDateTime": endDateTime.toIso8601String(), + "offerStatus": offerStatus, + "applyToPasses": applyToPasses, + }; +} + +/// ---------- CARD PASS ---------- +class CardPass { + final int id; + final String title; + final String description; + final int validityDuration; + final int adultPrice; + final int childPrice; + final int minNumber; // ✅ NEW + final int maxNumber; // ✅ NEW + final CardType cardType; + final List offers; + + CardPass({ + required this.id, + required this.title, + required this.description, + required this.validityDuration, + required this.adultPrice, + required this.childPrice, + required this.minNumber, + required this.maxNumber, + required this.cardType, + required this.offers, + }); + + factory CardPass.fromJson(Map json) { + return CardPass( + id: json['id'], + title: json['title'], + description: json['description'], + validityDuration: json['validityDuration'], + adultPrice: json['adultPrice'], + childPrice: json['childPrice'], + minNumber: json['minNumber'], // ✅ + maxNumber: json['maxNumber'], // ✅ + cardType: CardType.fromJson(json['cardType']), + offers: List.from( + json['offers'].map((x) => Offer.fromJson(x)), + ), + ); + } + + Map toJson() => { + "id": id, + "title": title, + "description": description, + "validityDuration": validityDuration, + "adultPrice": adultPrice, + "childPrice": childPrice, + "minNumber": minNumber, + "maxNumber": maxNumber, + "cardType": cardType.toJson(), + "offers": offers.map((x) => x.toJson()).toList(), + }; +} + +/// ---------- CARD TYPE ---------- +class CardType { + final int id; + final String name; + final String displayName; + + CardType({ + required this.id, + required this.name, + required this.displayName, + }); + + factory CardType.fromJson(Map json) { + return CardType( + id: json['id'], + name: json['name'], + displayName: json['displayName'], + ); + } + + Map toJson() => { + "id": id, + "name": name, + "displayName": displayName, + }; +} + +/// ---------- ATTRACTION ---------- +class Attraction { + final int id; + final String title; + final String slug; + final String thumbnail; + final int? startingFrom; + + Attraction({ + required this.id, + required this.title, + required this.slug, + required this.thumbnail, + this.startingFrom, + }); + + factory Attraction.fromJson(Map json) { + return Attraction( + id: json['id'], + title: json['title'], + slug: json['slug'], + thumbnail: json['thumbnail'], + startingFrom: json['startingFrom'], + ); + } + + Map toJson() => { + "id": id, + "title": title, + "slug": slug, + "thumbnail": thumbnail, + "startingFrom": startingFrom, + }; +} \ No newline at end of file diff --git a/lib/buy_a_pass/repository/buy_pass_repository.dart b/lib/buy_a_pass/repository/buy_pass_repository.dart new file mode 100644 index 0000000..944f8ad --- /dev/null +++ b/lib/buy_a_pass/repository/buy_pass_repository.dart @@ -0,0 +1,20 @@ +import 'package:citycards_customer/localPreference/local_preference.dart'; + +import '../models/buy_pass_model.dart'; +import '../../networkApiServices/network_api_services.dart'; +import '../../networkApiServices/api_urls.dart'; + +class BuyPassRepository { + final NetworkApiService _apiService = NetworkApiService(); + + /// Fetch Buy A Pass data using selected cityId + Future fetchBuyPass() async { + final int cityId = await LocalPreference.getSelectedCityId(); + + final response = await _apiService.getApi( + url: '${ApiUrls.buyAPass}/$cityId', + ); + + return BuyPassModel.fromJson(response.data); + } +} diff --git a/lib/buy_a_pass/view/buy_pass_view.dart b/lib/buy_a_pass/view/buy_pass_view.dart index 363cc30..7de6070 100644 --- a/lib/buy_a_pass/view/buy_pass_view.dart +++ b/lib/buy_a_pass/view/buy_pass_view.dart @@ -6,229 +6,458 @@ import 'package:citycards_customer/common_packages/custom_text.dart'; import 'package:citycards_customer/core/route_constants.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; - -import '../../common_packages/common_app_texts.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../networkApiServices/api_urls.dart'; +import '../bloc/buy_pass_bloc.dart'; +import '../bloc/buy_pass_event.dart'; +import '../bloc/buy_pass_state.dart'; +import '../repository/buy_pass_repository.dart'; class BuyPassView extends StatelessWidget { - BuyPassView({super.key}); + const BuyPassView({super.key}); - final availableAttraction = [ - {"image": "assets/images/aa1.png", "name": "Mystic Falls"}, - {"image": "assets/images/aa2.png", "name": "Whispering Pines"}, - {"image": "assets/images/aa3.png", "name": "Enchanted Oasis"}, - {"image": "assets/images/aa4.png", "name": "Serenity Cove"}, - ]; + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => BuyPassBloc(repository: BuyPassRepository()) + ..add(FetchBuyPassData()), + child: const BuyPassContent(), + ); + } +} - final offers = [ - { - "image": "assets/images/aa1.png", - "title": "Astor Hotels Ultra Deluxe", - "description": "15% Discount on all treatments for first-time clients", - }, - { - "image": "assets/images/aa2.png", - "title": "Green Valley Spa Lux", - "description": "20% off on spa memberships and treatments", - }, - ]; +class BuyPassContent extends StatelessWidget { + const BuyPassContent({super.key}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: SafeArea( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.0.w), - child: CommonAppBar(isWhiteLogo: false, isProfilePage: true,showDivider: true,), - ), - - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.0.w), - child: Row( - children: [ - GestureDetector( - onTap: () { - Navigator.pop(context); - }, - child: Icon(Icons.arrow_back), - ), - SizedBox(width: 8.w), - CustomText(text: "Buy a Pass", size: 12.sp), - ], - ), - ), - - SizedBox(height: 22.h), - - Padding( - padding: EdgeInsets.only(left: 20.0.w), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - PassCardView(themeColor: Color(0xFFF97316)), - SizedBox(width: 12.w), - PassCardView(themeColor: Color(0xFF1E8AF6),), - ], - ), - ), - ), - - SizedBox(height: 40.h), - FeatureTable(), - SizedBox(height: 30.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w), - child: Divider(color: Colors.black.withOpacity(0.1)), - ), - SizedBox(height: 30.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.0.w), - child: CustomText(text: "Available Attractions", size: 18.sp), - ), - SizedBox(height: 12.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - ...availableAttraction.map((item) { - return Padding( - padding: EdgeInsets.only(right: 12.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 104.h, - width: 104.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.r), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8.r), - child: Image.asset( - item["image"]!, - fit: BoxFit.cover, - ), - ), - ), - - CustomText(text: item["name"]!, size: 12.sp), - ], - ), - ); - }), - ], - ), - ), - ), - SizedBox(height: 20.h), - - Align( - alignment: Alignment.center, - child: CustomText( - text: "View All", - size: 12.sp, + child: BlocBuilder( + builder: (context, state) { + if (state is BuyPassLoading) { + return const Center( + child: CircularProgressIndicator( color: Color(0xFFF95F62), ), - ), - SizedBox(height: 30.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w), - child: Divider(color: Colors.black.withOpacity(0.1)), - ), - SizedBox(height: 40.h), - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + ); + } + + if (state is BuyPassError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - CustomText(text: "Card Offers", size: 18.sp), - GestureDetector( - onTap: (){ - Navigator.pushNamed(context,RouteConstants.searchOffer); + Icon(Icons.error_outline, size: 60.sp, color: Colors.red), + SizedBox(height: 16.h), + CustomText( + text: "Error loading data", + size: 16.sp, + color: Colors.red, + ), + SizedBox(height: 8.h), + CustomText( + text: state.message, + size: 12.sp, + color: Colors.grey, + ), + SizedBox(height: 20.h), + ElevatedButton( + onPressed: () { + context.read().add(FetchBuyPassData()); }, - child: CustomText( - text: "View All", - size: 14.sp, - color: Color(0xFFFF5757), - ), + child: const Text("Retry"), ), ], ), - ), - SizedBox(height: 16.h), - Container( - height: 262.h, - padding: EdgeInsets.symmetric(horizontal: 20.w), - child: GridView.builder( - physics: NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 16.w, - childAspectRatio: 0.66, - ), - itemCount: 2, - itemBuilder: (context, index) { - final offer = offers[index]; - return Container( - padding: EdgeInsets.symmetric( - horizontal: 6.w, - vertical: 6.h, + ); + } + + if (state is BuyPassLoaded) { + final data = state.data; + final selectedCard = state.selectedCard; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0.w), + child: CommonAppBar( + isWhiteLogo: false, + isProfilePage: true, + showDivider: true, ), - decoration: BoxDecoration( - border: Border.all( - color: Color(0xFFF95F62).withOpacity(.24), - ), - borderRadius: BorderRadius.circular(12.sp), - ), - child: Column( + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0.w), + child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8.sp), - child: Image.asset( - offer["image"] ?? "", - width: double.infinity, - height: 120.5.h, - fit: BoxFit.cover, - ), + GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Icon(Icons.arrow_back), ), - SizedBox(height: 8.h), - CustomText(text: offer["title"] ?? "", size: 18.sp), - SizedBox(height: 8.h), - CustomText( - text: offer["description"] ?? "", - color: Colors.black.withOpacity(.6), - size: 12.sp, - maxLines: 2, - overflow: TextOverflow.ellipsis, + SizedBox(width: 8.w), + CustomText(text: "Buy a Pass", size: 12.sp), + ], + ), + ), + SizedBox(height: 22.h), + + // Pass Cards Horizontal List + Padding( + padding: EdgeInsets.only(left: 20.0.w), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate( + data.cards.length, + (index) { + final card = data.cards[index]; + final isSelected = index == state.selectedCardIndex; + + return GestureDetector( + onTap: () { + context.read().add( + ChangeSelectedCard(index), + ); + }, + child: Padding( + padding: EdgeInsets.only(right: 12.w), + child: PassCardView( + themeColor: isSelected + ? Color(0xFFF97316) + : Color(0xFF1E8AF6), + city: data.city.name, + adultPrice: card.adultPrice, + childPrice: card.childPrice, + cardType: card.cardType.displayName, + description: card.description, + isSelected: isSelected, + ), + ), + ); + }, + ), + ), + ), + ), + + SizedBox(height: 30.h), + + // Payment Card + Center( + // Updated PaymentCard usage with BLoC + + child: PaymentCard( + city: data.city.name, + cardType: selectedCard.cardType.name, + cardDisplayName: selectedCard.cardType.displayName, + adultPrice: selectedCard.adultPrice.toDouble(), + childPrice: selectedCard.childPrice.toDouble(), + adults: state.adultCount, + children: state.childCount, + totalPrice: state.totalPrice, + minNumber: selectedCard.minNumber, + maxNumber: selectedCard.maxNumber, + selectedValue: state.validityDuration, // Use from BLoC state + onAdultChanged: (count) { + context.read().add( + UpdateAdultCount(count), + ); + }, + onChildChanged: (count) { + context.read().add( + UpdateChildCount(count), + ); + }, + onValidityChanged: (duration) { + context.read().add( + UpdateValidityDuration(duration), + ); + }, + ), + ), + + SizedBox(height: 20.h), + FeatureTable(), + SizedBox(height: 30.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Divider(color: Colors.black.withOpacity(0.1)), + ), + SizedBox(height: 40.h), + + // Card Offers Section + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CustomText(text: "Card Offers", size: 18.sp), + GestureDetector( + onTap: () { + Navigator.pushNamed( + context, RouteConstants.searchOffer); + }, + child: CustomText( + text: "View All", + size: 14.sp, + color: Color(0xFFFF5757), + ), ), ], ), - ); - }, - ), - ), + ), + SizedBox(height: 16.h), - SizedBox(height: 41.h), - Center( - child: PaymentCard( - city: 'Melbourne', - tag: '${CommonAppText.selectiveCard} Card', - oldPrice: 120, - newPrice: 90, + // Offers Grid (from selected card's offers) + if (selectedCard.offers.isNotEmpty) + Container( + height: 262.h, + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16.w, + mainAxisSpacing: 22.h, + childAspectRatio: 0.65, + ), + itemCount: selectedCard.offers.length > 2 + ? 2 + : selectedCard.offers.length, + itemBuilder: (context, index) { + final offer = selectedCard.offers[index]; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: 6.w, + vertical: 6.h, + ), + decoration: BoxDecoration( + border: Border.all( + color: const Color(0xFFF95F62).withOpacity(.24), + ), + borderRadius: BorderRadius.circular(12.sp), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// Image + ClipRRect( + borderRadius: BorderRadius.circular(8.sp), + child: offer.mobileBannerImage != null && + offer.mobileBannerImage!.isNotEmpty + ? Image.network( + '${ApiUrls.baseUrl}/${offer.mobileBannerImage}', + width: double.infinity, + height: 120.5.h, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: double.infinity, + height: 120.5.h, + color: const Color(0xFFFEE7E7), + child: Icon( + Icons.local_offer, + size: 40.sp, + color: + const Color(0xFFF95F62).withOpacity(.6), + ), + ); + }, + loadingBuilder: + (context, child, loadingProgress) { + if (loadingProgress == null) return child; + + return Container( + width: double.infinity, + height: 120.5.h, + color: const Color(0xFFFEE7E7), + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: const Color(0xFFF95F62), + value: loadingProgress + .expectedTotalBytes != + null + ? loadingProgress + .cumulativeBytesLoaded / + loadingProgress + .expectedTotalBytes! + : null, + ), + ), + ); + }, + ) + : Container( + width: double.infinity, + height: 120.5.h, + color: const Color(0xFFFEE7E7), + child: Icon( + Icons.local_offer, + size: 40.sp, + color: + const Color(0xFFF95F62).withOpacity(.6), + ), + ), + ), + + SizedBox(height: 8.h), + + /// Title + CustomText( + text: offer.title, + size: 18.sp, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + SizedBox(height: 8.h), + + /// Offer Code + CustomText( + text: offer.description??"N/A", + color: Colors.black.withOpacity(.6), + size: 12.sp, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ), + ) + else + Container( + height: 100.h, + alignment: Alignment.center, + child: CustomText( + text: "No offers available", + size: 14.sp, + color: Colors.grey, + ), + ), + + SizedBox(height: 30.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Divider(color: Colors.black.withOpacity(0.1)), + ), + SizedBox(height: 30.h), + + // Available Attractions + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0.w), + child: CustomText( + text: "Available Attractions", size: 18.sp), + ), + SizedBox(height: 12.h), + + if (data.attractions.isNotEmpty) + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: data.attractions.map((attraction) { + return Padding( + padding: EdgeInsets.only(right: 12.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 104.h, + width: 104.w, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8.r), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.r), + child: attraction.thumbnail != null && + attraction.thumbnail!.isNotEmpty + ? Image.network( + attraction.thumbnail!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.location_on, + size: 40.sp, + color: Colors.grey[400], + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: SizedBox( + width: 20.w, + height: 20.w, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + ) + : Icon( + Icons.location_on, + size: 40.sp, + color: Colors.grey[400], + ), + ), + ), + SizedBox(height: 4.h), + SizedBox( + width: 104.w, + child: CustomText( + text: attraction.title, + size: 12.sp, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ) + else + Container( + height: 100.h, + alignment: Alignment.center, + child: CustomText( + text: "No attractions available", + size: 14.sp, + color: Colors.grey, + ), + ), + + SizedBox(height: 20.h), + Align( + alignment: Alignment.center, + child: CustomText( + text: "View All", + size: 12.sp, + color: Color(0xFFF95F62), + ), + ), + SizedBox(height: 41.h), + ], ), - ), - SizedBox(height: 20.h), - ], - ), + ); + } + + return const SizedBox(); + }, ), ), ); } -} +} \ No newline at end of file diff --git a/lib/buy_a_pass/widget/pass_card_view.dart b/lib/buy_a_pass/widget/pass_card_view.dart index d4aac1f..7c33ca4 100644 --- a/lib/buy_a_pass/widget/pass_card_view.dart +++ b/lib/buy_a_pass/widget/pass_card_view.dart @@ -2,22 +2,24 @@ import 'package:citycards_customer/common_packages/custom_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../../common_packages/common_app_texts.dart'; - class PassCardView extends StatelessWidget { final Color? themeColor; final String? city; - final int? adultCount; - final int? childCount; + final int? adultPrice; + final int? childPrice; final String? cardType; + final String? description; + final bool isSelected; const PassCardView({ super.key, this.themeColor, this.city, - this.adultCount, - this.childCount, + this.adultPrice, + this.childPrice, this.cardType, + this.description, + this.isSelected = false, }); @override @@ -25,141 +27,161 @@ class PassCardView extends StatelessWidget { return Container( decoration: BoxDecoration( color: Colors.white, - border: Border.all(color:( themeColor ?? Color(0xFFF95FAF)).withOpacity(0.24)), + border: Border.all( + color: (themeColor ?? Color(0xFFF95FAF)).withOpacity(0.24), + width: isSelected ? 2 : 1, + ), borderRadius: BorderRadius.circular(8.r), + // boxShadow: isSelected + // ? [ + // BoxShadow( + // color: (themeColor ?? Color(0xFFF95FAF)).withOpacity(0.3), + // blurRadius: 8, + // spreadRadius: 1, + // ) + // ] + // : [], ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(8.r), - bottomLeft: Radius.circular(8.r) - ), - child: Image.asset( - "assets/images/card_banner.png", - scale: 4, - width: 103.w, - height:140.h, - fit: BoxFit.cover, - ), - ), - SizedBox(width: 6.66.w), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CustomText( - text: "Melbourne", - weight: FontWeight.w500, - size: 16.sp, - ), - - Row( - children: [ - Text( - "From ", - style: TextStyle( - color: Colors.black.withOpacity(0.6), - fontSize: 11.sp, - fontWeight: FontWeight.w400, - ), - ), - Text( - "\$80", - style: TextStyle( - color:themeColor, - fontWeight: FontWeight.w500, - fontSize: 24.sp, - ), - ), - Text( - " /Adult", - style: TextStyle( - color: Colors.black.withOpacity(0.8), - fontSize: 11.sp, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - - Row( - children: [ - Text( - "and ", - style: TextStyle( - color: Colors.black.withOpacity(0.6), - fontSize: 11.sp, - fontWeight: FontWeight.w400, - ), - ), - Text( - "\$10", - style: TextStyle( - color: themeColor, - fontWeight: FontWeight.w500, - fontSize: 24.sp, - ), - ), - Text( - " /child", - style: TextStyle( - color: Colors.black.withOpacity(0.8), - fontSize: 11.sp, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - - SizedBox( - width: 193.w, - child: CustomText( - text: - "Dive into an extensive selection of thrilling destinations!", - color: Color(0xFF000000).withOpacity(0.6), - size: 11.sp, - ), - ), - ], - ), - ], - ), - - Container( - width: 35.w, - height: 140.h, - decoration: BoxDecoration( - color: themeColor, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + // Banner Image Placeholder + ClipRRect( borderRadius: BorderRadius.only( - bottomRight: Radius.circular(8.r), - topRight: Radius.circular(8.r), + topLeft: Radius.circular(8.r), + bottomLeft: Radius.circular(8.r), ), - ), - child: RotatedBox( - quarterTurns: -1, - child: Center( - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: "${CommonAppText.selectiveCard} ", - style: TextStyle(color: Colors.white, fontSize: 16.sp), - ), - TextSpan( - text: "Card", - style: TextStyle(color: Colors.white, fontSize: 12.sp), - ), - ], - ), + child: Container( + width: 103.w, + height: 140.h, + color: Colors.grey[200], + child: Icon( + Icons.card_travel, + size: 40.sp, + color: Colors.grey[400], ), ), ), + SizedBox(width: 6.66.w), + + // Card Details + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CustomText( + text: city ?? "City", + weight: FontWeight.w500, + size: 16.sp, + ), + + // Adult Price + Row( + children: [ + Text( + "From ", + style: TextStyle( + color: Colors.black.withOpacity(0.6), + fontSize: 11.sp, + fontWeight: FontWeight.w400, + ), + ), + Text( + "\$${adultPrice ?? 0}", + style: TextStyle( + color: themeColor, + fontWeight: FontWeight.w500, + fontSize: 24.sp, + ), + ), + Text( + " /Adult", + style: TextStyle( + color: Colors.black.withOpacity(0.8), + fontSize: 11.sp, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + + // Child Price + Row( + children: [ + Text( + "and ", + style: TextStyle( + color: Colors.black.withOpacity(0.6), + fontSize: 11.sp, + fontWeight: FontWeight.w400, + ), + ), + Text( + "\$${childPrice ?? 0}", + style: TextStyle( + color: themeColor, + fontWeight: FontWeight.w500, + fontSize: 24.sp, + ), + ), + Text( + " /child", + style: TextStyle( + color: Colors.black.withOpacity(0.8), + fontSize: 11.sp, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + + // Description + SizedBox( + width: 193.w, + child: CustomText( + text: description ?? + "Dive into an extensive selection of thrilling destinations!", + color: Color(0xFF000000).withOpacity(0.6), + size: 11.sp, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + + // Card Type Label (Vertical) + Container( + width: 35.w, + height: 140.h, + decoration: BoxDecoration( + color: themeColor, + borderRadius: BorderRadius.only( + bottomRight: Radius.circular(8.r), + topRight: Radius.circular(8.r), + ), ), - ], - ), + child: RotatedBox( + quarterTurns: -1, + child: Center( + child: Text( + cardType ?? "Pass", + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ], + ), ); - } } +} \ No newline at end of file diff --git a/lib/buy_a_pass/widget/payment_card_view.dart b/lib/buy_a_pass/widget/payment_card_view.dart index 406837b..58b02e8 100644 --- a/lib/buy_a_pass/widget/payment_card_view.dart +++ b/lib/buy_a_pass/widget/payment_card_view.dart @@ -1,158 +1,254 @@ import 'package:citycards_customer/common_packages/custom_filled_button.dart'; +import 'package:citycards_customer/common_packages/custom_text.dart'; import 'package:citycards_customer/core/route_constants.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -class PaymentCard extends StatefulWidget { +class PaymentCard extends StatelessWidget { final String city; - final String tag; - final double oldPrice; - final double newPrice; + final String cardType; // "unlimited_card" or "selective_pass" + final String cardDisplayName; + final double adultPrice; + final double childPrice; + final int adults; + final int children; + final double totalPrice; + final int minNumber; + final int maxNumber; + final int selectedValue; // Current selected value for dropdown + final Function(int) onAdultChanged; + final Function(int) onChildChanged; + final Function(int) onValidityChanged; const PaymentCard({ super.key, required this.city, - required this.tag, - required this.oldPrice, - required this.newPrice, + required this.cardType, + required this.cardDisplayName, + required this.adultPrice, + required this.childPrice, + required this.adults, + required this.children, + required this.totalPrice, + required this.minNumber, + required this.maxNumber, + required this.selectedValue, + required this.onAdultChanged, + required this.onChildChanged, + required this.onValidityChanged, }); - @override - State createState() => _PaymentCardState(); -} - -class _PaymentCardState extends State { - int adults = 1; - int children = 1; - @override Widget build(BuildContext context) { - return Container( - width: 320, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.pinkAccent, width: 1.2), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.pinkAccent.withOpacity(0.1), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Title - Text( - widget.city, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), + // Determine if it's unlimited card or selective pass + final bool isUnlimitedCard = cardType == "unlimited_card"; + final bool isSelectivePass = cardType == "selective_pass"; - const SizedBox(height: 6), - - // Tag - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - color: Color(0xFFF95FAF), - borderRadius: BorderRadius.circular(20), + return Padding( + padding: const EdgeInsets.all(12.0), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(20.sp), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.pinkAccent, width: 1.2), + borderRadius: BorderRadius.circular(12.r), + boxShadow: [ + BoxShadow( + color: Colors.pinkAccent.withOpacity(0.1), + blurRadius: 10, + spreadRadius: 2, ), - child: Text( - widget.tag, - style: const TextStyle( + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Title + CustomText( + text: city, + size: 20.sp, + weight: FontWeight.bold, + ), + + SizedBox(height: 6.h), + + // Tag + Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + decoration: BoxDecoration( + color: Color(0xFFF95FAF), + borderRadius: BorderRadius.circular(20.r), + ), + child: CustomText( + text: "$cardDisplayName Card", + size: 12.sp, color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w500, + weight: FontWeight.w500, ), ), - ), - const SizedBox(height: 16), + SizedBox(height: 16.h), - // Adult Counter - _buildCounterRow("No. of Adults", adults, (val) { - setState(() => adults = val); - }), + // Adult Counter + _buildCounterRow( + "No. of Adults", + adults, + onAdultChanged, + ), - const SizedBox(height: 10), + SizedBox(height: 10.h), - // Children Counter - _buildCounterRow("No. of Children", children, (val) { - setState(() => children = val); - }), + // Children Counter + _buildCounterRow( + "No. of Children", + children, + onChildChanged, + ), - const Divider(height: 30, thickness: 1), + SizedBox(height: 10.h), - // Price section - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - "You Pay", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + // Show days dropdown for unlimited_card OR attractions dropdown for selective_pass + if (isUnlimitedCard) + _buildDropdownRow( + label: "No. of Days", + value: selectedValue, + onChanged: onValidityChanged, + ) + else if (isSelectivePass) + _buildDropdownRow( + label: "No. of Attractions", + value: selectedValue, + onChanged: onValidityChanged, ), - Row( - children: [ - Text( - "\$${widget.oldPrice.toStringAsFixed(0)}", - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - decoration: TextDecoration.lineThrough, - ), - ), - const SizedBox(width: 8), - Text( - "\$${widget.newPrice.toStringAsFixed(0)}", - style: const TextStyle( + + Divider(height: 30.h, thickness: 1), + + // Price section + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CustomText( + text: "You Pay", + size: 16.sp, + weight: FontWeight.w500, + ), + Row( + children: [ + // Calculate original price (without any discount logic for now) + CustomText( + text: "\$${totalPrice.toStringAsFixed(0)}", + size: 18.sp, color: Color(0xFFF95F62), - fontWeight: FontWeight.bold, - fontSize: 18, + weight: FontWeight.bold, ), - ), - ], - ), - ], - ), + ], + ), + ], + ), - const SizedBox(height: 20), + SizedBox(height: 20.h), - // Proceed Button - CustomFilledButton( - onTap: () { - Navigator.of( - context, - ).pushNamed(RouteConstants.checkout); - }, - label: "Proceed to Pay", - ), - ], + // Proceed Button + CustomFilledButton( + onTap: () { + Navigator.of(context).pushNamed(RouteConstants.checkout); + }, + label: "Proceed to Pay", + ), + ], + ), ), ); } - Widget _buildCounterRow(String label, int value, Function(int) onChanged) { + /// Dropdown row for days or attractions count + Widget _buildDropdownRow({ + required String label, + required int value, + required Function(int) onChanged, + }) { + List numbersList = List.generate( + maxNumber - minNumber + 1, + (index) => minNumber + index, + ); + return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: TextStyle(fontSize: 15.sp)), + CustomText( + text: label, + size: 15.sp, + ), + + Container( + height: 36.h, + width: 88.w, // 👈 fixed width for proper spacing + padding: EdgeInsets.symmetric(horizontal: 14.w), + decoration: BoxDecoration( + color: Color(0xFFF95F62).withValues(alpha: 0.13), + border: Border.all( + color: const Color(0xFFF95F62), + width: 1.4, + ), + borderRadius: BorderRadius.circular(16.r), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + icon: Icon( + Icons.keyboard_arrow_down_rounded, + color: const Color(0xFFF95F62), + size: 22.sp, + ), + items: numbersList.map((int number) { + return DropdownMenuItem( + value: number, + child: Align( + alignment: Alignment.centerLeft, // 🔢 number fully left + child: CustomText( + text: "$number", + size: 16.sp, + weight: FontWeight.bold, + ), + ), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + onChanged(newValue); + } + }, + ), + ), + ), + ], + ); + } + + /// Counter row for adults/children + Widget _buildCounterRow( + String label, + int value, + Function(int) onChanged, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CustomText(text: label, size: 15.sp), Row( children: [ _circleButton(Icons.remove, () { if (value > 0) onChanged(value - 1); }), Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text( - "$value", - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - ), + padding: EdgeInsets.symmetric(horizontal: 10.w), + child: CustomText( + text: "$value", + size: 16.sp, + weight: FontWeight.bold, ), ), _circleButton(Icons.add, () { @@ -164,6 +260,7 @@ class _PaymentCardState extends State { ); } + /// Circle button for increment/decrement Widget _circleButton(IconData icon, VoidCallback onTap) { return InkWell( onTap: onTap, @@ -173,9 +270,9 @@ class _PaymentCardState extends State { shape: BoxShape.circle, color: Color(0xFFF95F62), ), - padding: const EdgeInsets.all(4), + padding: EdgeInsets.all(4.sp), child: Icon(icon, color: Colors.white, size: 18.sp), ), ); } -} +} \ No newline at end of file diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index dd0c2c9..f733105 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -7,7 +7,6 @@ import 'package:citycards_customer/checkout/view/checkout_view.dart'; import 'package:citycards_customer/common_bloc/language_selection_bloc.dart'; import 'package:citycards_customer/contact_us/contact_us_view.dart'; import 'package:citycards_customer/create_account/view/create_account_view.dart'; -import 'package:citycards_customer/edit_profile/edit_profile_view.dart'; import 'package:citycards_customer/esim_offer/esim_offer_view.dart'; import 'package:citycards_customer/hotel_offer/hotel_offer_view.dart'; import 'package:citycards_customer/intro_screens/views/intro_screen_view.dart'; @@ -30,10 +29,13 @@ import '../cart/views/my_cart_view_page.dart'; import '../common_bloc/bottom_navigation_bloc.dart'; import '../home/views/home_page_view.dart'; import '../home/views/registered_user_home_page.dart'; +import '../profile/view/edit_profile/edit_profile_view.dart'; import '../profile/view/faq/faq_view.dart'; import '../profile/view/privacy/privacy_view.dart'; import '../profile/view/profile_page_view.dart'; import '../profile/view/terms_and_condition/terms_and_condition_view.dart'; +import '../search_offers/bloc/offers_bloc.dart'; +import '../search_offers/repository/offers_repository.dart'; import 'route_constants.dart'; class AppRouter { @@ -180,8 +182,8 @@ class AppRouter { return MaterialPageRoute( builder: (_) { return BlocProvider( - create: (_) => OffersBloc(), - child: SearchOffersWithListing(), + create: (_) => OffersBloc(OffersRepository()), + child: OffersScreen(), ); }, ); @@ -226,12 +228,15 @@ class AppRouter { ); case RouteConstants.offerPassDetail: + final offerId = settings.arguments as int; + return MaterialPageRoute( - builder: (_) { - return OfferPassDetailView(); - }, + builder: (_) => OffersDetailsView( + offerId: offerId, + ), ); + case RouteConstants.registeredUserHome: return MaterialPageRoute( builder: (_) { diff --git a/lib/core/inside_bottom_navigator.dart b/lib/core/inside_bottom_navigator.dart index 95017ca..70d82ba 100644 --- a/lib/core/inside_bottom_navigator.dart +++ b/lib/core/inside_bottom_navigator.dart @@ -22,7 +22,9 @@ import '../my_pass/views/qr_pass_page_view.dart'; import '../offer_pass_detail/offer_pass_detail_view.dart'; import '../postcard/blocs/postcard_creation_bloc.dart'; import '../postcard/views/postcard_creation_page_view.dart'; +import '../search_offers/bloc/offers_bloc.dart'; import '../search_offers/bloc/search_offers_listing_bloc.dart'; +import '../search_offers/repository/offers_repository.dart'; import '../search_offers/view/search_offers_with_listing.dart'; import '../your_itinerary/view/your_itinerary_view.dart'; @@ -80,16 +82,20 @@ Widget buildOffstageNavigator( ); case RouteConstants.offerPassDetail: - return MaterialPageRoute(builder: (_){ - return OfferPassDetailView(); - }); + final offerId = settings.arguments as int; + + return MaterialPageRoute( + builder: (_) => OffersDetailsView( + offerId: offerId, + ), + ); case RouteConstants.searchOffer: return MaterialPageRoute( builder: (_) { return BlocProvider( - create: (_) => OffersBloc(), - child: SearchOffersWithListing(), + create: (_) => OffersBloc(OffersRepository()), + child: OffersScreen(), ); }, ); diff --git a/lib/main.dart b/lib/main.dart index 67bd215..445ee18 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:citycards_customer/cart/blocs/postcard_bloc.dart'; import 'package:citycards_customer/core/route_constants.dart'; +import 'package:citycards_customer/search_offers/bloc/offers_bloc.dart'; import 'package:citycards_customer/trail.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -16,6 +17,8 @@ import 'login/bloc/login/login_bloc.dart'; import 'login/repository/login_repository.dart'; import 'my_pass/blocs/my_pass_bloc.dart'; import 'profile/bloc/profile/profile_bloc.dart'; +import 'search_offers/repository/offers_repository.dart'; +import 'search_offers/view/search_offers_with_listing.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -61,7 +64,12 @@ class MyApp extends StatelessWidget { loginRepository: LoginRepository(), ), ), - BlocProvider(create: (context) => ProfileBloc()), + BlocProvider( + create: (_) => OffersBloc(OffersRepository()), + child: const OffersScreen(), + ), + BlocProvider(create: (context) => ProfileBloc()), + ], child: MaterialApp( onGenerateRoute: _appRouter.onGenerateRoute, diff --git a/lib/networkApiServices/api_urls.dart b/lib/networkApiServices/api_urls.dart index 00deac3..07c1262 100644 --- a/lib/networkApiServices/api_urls.dart +++ b/lib/networkApiServices/api_urls.dart @@ -12,6 +12,9 @@ class ApiUrls { static const home = "$baseUrl/mobile"; static const faqPrivacyTerms = "$baseUrl/mobile/user/cms-data"; static const userProfile = "$baseUrl/mobile/user"; + static const offers = "$baseUrl/mobile/list/offers"; + static const buyAPass = "$baseUrl/mobile/pass"; + static const offersDetails = "$baseUrl/mobile/list/offers"; //Post Apis diff --git a/lib/offer_pass_detail/bloc/offer_details_bloc.dart b/lib/offer_pass_detail/bloc/offer_details_bloc.dart new file mode 100644 index 0000000..96d98df --- /dev/null +++ b/lib/offer_pass_detail/bloc/offer_details_bloc.dart @@ -0,0 +1,35 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../repository/offers_details_repository.dart'; +import 'offer_details_event.dart'; +import 'offer_details_state.dart'; + +class OfferDetailsBloc + extends Bloc { + final OffersDetailsRepository repository; + + OfferDetailsBloc({required this.repository}) + : super(OfferDetailsInitial()) { + on(_onFetchOfferDetails); + } + + Future _onFetchOfferDetails( + FetchOfferDetailsEvent event, + Emitter emit, + ) async { + emit(OfferDetailsLoading()); + + try { + final offerDetails = await repository.fetchOfferDetails( + offerId: event.offerId, + ); + + emit(OfferDetailsLoaded(offerDetails: offerDetails)); + } catch (e) { + emit( + OfferDetailsError( + message: e.toString(), + ), + ); + } + } +} diff --git a/lib/offer_pass_detail/bloc/offer_details_event.dart b/lib/offer_pass_detail/bloc/offer_details_event.dart new file mode 100644 index 0000000..70451aa --- /dev/null +++ b/lib/offer_pass_detail/bloc/offer_details_event.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +abstract class OfferDetailsEvent extends Equatable { + const OfferDetailsEvent(); + + @override + List get props => []; +} + +/// Fetch offer details by offerId +class FetchOfferDetailsEvent extends OfferDetailsEvent { + final int offerId; + + const FetchOfferDetailsEvent({required this.offerId}); + + @override + List get props => [offerId]; +} diff --git a/lib/offer_pass_detail/bloc/offer_details_state.dart b/lib/offer_pass_detail/bloc/offer_details_state.dart new file mode 100644 index 0000000..3ad8860 --- /dev/null +++ b/lib/offer_pass_detail/bloc/offer_details_state.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; +import '../models/offer_details_model.dart'; + +abstract class OfferDetailsState extends Equatable { + const OfferDetailsState(); + + @override + List get props => []; +} + +/// Initial state +class OfferDetailsInitial extends OfferDetailsState {} + +/// Loading state +class OfferDetailsLoading extends OfferDetailsState {} + +/// Success state +class OfferDetailsLoaded extends OfferDetailsState { + final OfferDetailsModel offerDetails; + + const OfferDetailsLoaded({required this.offerDetails}); + + @override + List get props => [offerDetails]; +} + +/// Error state +class OfferDetailsError extends OfferDetailsState { + final String message; + + const OfferDetailsError({required this.message}); + + @override + List get props => [message]; +} diff --git a/lib/offer_pass_detail/models/offer_details_model.dart b/lib/offer_pass_detail/models/offer_details_model.dart new file mode 100644 index 0000000..cfea3ca --- /dev/null +++ b/lib/offer_pass_detail/models/offer_details_model.dart @@ -0,0 +1,199 @@ +// ======================= +// OFFER DETAILS MODEL +// ======================= + +class OfferDetailsModel { + final int id; + final String title; + final String description; + final int cityXid; + final int cardXid; + final int cardTypeXid; + final int categoryXid; + final String partnerName; + final String offerCode; + final String websiteBannerImage; + final String mobileBannerImage; + final String redemptionLink; + final String passType; + final DateTime startDateTime; + final DateTime endDateTime; + final bool applyToPasses; + final String? stepsForBooking; + final String offerStatus; + final bool isActive; + final DateTime createdAt; + final DateTime updatedAt; + final City city; + final CardInfo card; + final CardType cardType; + final Category category; + + OfferDetailsModel({ + required this.id, + required this.title, + required this.description, + required this.cityXid, + required this.cardXid, + required this.cardTypeXid, + required this.categoryXid, + required this.partnerName, + required this.offerCode, + required this.websiteBannerImage, + required this.mobileBannerImage, + required this.redemptionLink, + required this.passType, + required this.startDateTime, + required this.endDateTime, + required this.applyToPasses, + this.stepsForBooking, + required this.offerStatus, + required this.isActive, + required this.createdAt, + required this.updatedAt, + required this.city, + required this.card, + required this.cardType, + required this.category, + }); + + factory OfferDetailsModel.fromJson(Map json) { + return OfferDetailsModel( + id: json['id'] ?? 0, + title: json['title'] ?? 'N/A', + description: json['description'] ?? 'N/A', + cityXid: json['cityXid'] ?? 0, + cardXid: json['cardXid'] ?? 0, + cardTypeXid: json['cardTypeXid'] ?? 0, + categoryXid: json['categoryXid'] ?? 0, + partnerName: json['partnerName'] ?? 'N/A', + offerCode: json['offerCode'] ?? 'N/A', + websiteBannerImage: json['websiteBannerImage'] ?? '', + mobileBannerImage: json['mobileBannerImage'] ?? '', + redemptionLink: json['redemptionLink'] ?? '', + passType: json['passType'] ?? 'N/A', + startDateTime: json['startDateTime'] != null + ? DateTime.parse(json['startDateTime']) + : DateTime.now(), + endDateTime: json['endDateTime'] != null + ? DateTime.parse(json['endDateTime']) + : DateTime.now(), + applyToPasses: json['applyToPasses'] ?? false, + stepsForBooking: json['stepsForBooking'], + offerStatus: json['offerStatus'] ?? 'N/A', + isActive: json['isActive'] ?? false, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : DateTime.now(), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt']) + : DateTime.now(), + city: json['city'] != null + ? City.fromJson(json['city']) + : City.empty(), + card: json['card'] != null + ? CardInfo.fromJson(json['card']) + : CardInfo.empty(), + cardType: json['cardType'] != null + ? CardType.fromJson(json['cardType']) + : CardType.empty(), + category: json['category'] != null + ? Category.fromJson(json['category']) + : Category.empty(), + ); + } +} + +// ======================= +// CITY +// ======================= + +class City { + final int id; + final String cityName; + + City({ + required this.id, + required this.cityName, + }); + + factory City.fromJson(Map json) { + return City( + id: json['id'] ?? 0, + cityName: json['cityName'] ?? 'N/A', + ); + } + + factory City.empty() => City(id: 0, cityName: 'N/A'); +} + +// ======================= +// CARD INFO +// ======================= + +class CardInfo { + final int id; + final String title; + + CardInfo({ + required this.id, + required this.title, + }); + + factory CardInfo.fromJson(Map json) { + return CardInfo( + id: json['id'] ?? 0, + title: json['title'] ?? 'N/A', + ); + } + + factory CardInfo.empty() => CardInfo(id: 0, title: 'N/A'); +} + +// ======================= +// CARD TYPE +// ======================= + +class CardType { + final int id; + final String cardTypeDisplayName; + + CardType({ + required this.id, + required this.cardTypeDisplayName, + }); + + factory CardType.fromJson(Map json) { + return CardType( + id: json['id'] ?? 0, + cardTypeDisplayName: json['cardTypeDisplayName'] ?? 'N/A', + ); + } + + factory CardType.empty() => + CardType(id: 0, cardTypeDisplayName: 'N/A'); +} + +// ======================= +// CATEGORY +// ======================= + +class Category { + final int id; + final String categoryName; + + Category({ + required this.id, + required this.categoryName, + }); + + factory Category.fromJson(Map json) { + return Category( + id: json['id'] ?? 0, + categoryName: json['categoryName'] ?? 'N/A', + ); + } + + factory Category.empty() => + Category(id: 0, categoryName: 'N/A'); +} diff --git a/lib/offer_pass_detail/offer_pass_detail_view.dart b/lib/offer_pass_detail/offer_pass_detail_view.dart index 488c17f..f4572ff 100644 --- a/lib/offer_pass_detail/offer_pass_detail_view.dart +++ b/lib/offer_pass_detail/offer_pass_detail_view.dart @@ -3,244 +3,298 @@ import 'package:citycards_customer/common_packages/app_bar.dart'; import 'package:citycards_customer/common_packages/custom_bullet_points.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -class OfferPassDetailView extends StatelessWidget { - const OfferPassDetailView({super.key}); +import '../networkApiServices/api_urls.dart'; +import 'bloc/offer_details_bloc.dart'; +import 'bloc/offer_details_event.dart'; +import 'bloc/offer_details_state.dart'; +import 'repository/offers_details_repository.dart'; + +class OffersDetailsView extends StatelessWidget { + final int offerId; + + const OffersDetailsView({ + super.key, + required this.offerId, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => OfferDetailsBloc( + repository: OffersDetailsRepository(), // ← Create directly + )..add(FetchOfferDetailsEvent(offerId: offerId)), + child: const _OffersDetailsContent(), + ); + } +} + +class _OffersDetailsContent extends StatelessWidget { + const _OffersDetailsContent(); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: SafeArea( - child: SingleChildScrollView( - child: Column( - children: [ - Stack( - children: [ - Image.asset( - 'assets/images/koh_rong_samloem_banner.png', - height: 377.h, - width: double.infinity, - fit: BoxFit.cover, - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 20.w, - vertical: 10.h, + child: BlocBuilder( + builder: (context, state) { + if (state is OfferDetailsLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is OfferDetailsError) { + return Center(child: Text(state.message)); + } + + if (state is OfferDetailsLoaded) { + final offer = state.offerDetails; + + return SingleChildScrollView( + child: Column( + children: [ + Stack( + children: [ + /// 🔥 Banner Image (API + Placeholder) + FadeInImage.assetNetwork( + placeholder: + 'assets/images/koh_rong_samloem_banner.png', + image: '${ApiUrls.baseUrl}/''${offer.mobileBannerImage}', + height: 377.h, + width: double.infinity, + fit: BoxFit.cover, + imageErrorBuilder: + (context, error, stackTrace) { + return Image.asset( + 'assets/images/koh_rong_samloem_banner.png', + height: 377.h, + width: double.infinity, + fit: BoxFit.cover, + ); + }, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CommonAppBar( - isWhiteLogo: false, - isProfilePage: false, - showDivider: true, - ), - SizedBox(height: 8.h), - - Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Icon( - Icons.arrow_back, - size: 24.sp, - color: Colors.white, + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 20.w, + vertical: 10.h, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CommonAppBar( + isWhiteLogo: false, + isProfilePage: false, + showDivider: true, ), + SizedBox(height: 8.h), + Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon( + Icons.arrow_back, + size: 24.sp, + color: Colors.white, + ), + ), + SizedBox(width: 8.w), + Text( + offer.partnerName, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ], + ), + ), + ), + ), + + Positioned( + bottom: 31.h, + left: 12.w, + child: Text( + offer.partnerName, + style: TextStyle( + color: Colors.white, + fontSize: 48.sp, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + ), + + Positioned( + bottom: 31.h, + right: 17.w, + child: GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => const ShareBottomSheet(), + ); + }, + child: Container( + height: 36.h, + width: 36.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20.r), + ), + child: Center( + child: Icon( + Icons.share_sharp, + color: Colors.black, + size: 18.sp, ), - SizedBox(width: 8.w), + ), + ), + ), + ), + ], + ), + + Padding( + padding: EdgeInsets.symmetric( + horizontal: 20.w, + vertical: 30.5.h, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "About ${offer.partnerName}", + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8.h), + + Text( + offer.title, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + ), + ), + SizedBox(height: 10.h), + + Text( + offer.description, + style: TextStyle( + fontSize: 14.sp, + height: 1.4, + color: const Color(0xFF656565), + ), + ), + SizedBox(height: 40.h), + + Text( + "How to make a booking?", + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w400, + ), + ), + SizedBox(height: 16.h), + + const CustomBulletPoints( + text: + "Check the expiration date of your coupon to ensure it's still valid.", + textColor: Color(0xFF656565), + ), + const CustomBulletPoints( + text: + "Visit the store or website where the coupon can be redeemed.", + textColor: Color(0xFF656565), + ), + const CustomBulletPoints( + text: + "If shopping online, add items to your cart and proceed to checkout.", + textColor: Color(0xFF656565), + ), + const CustomBulletPoints( + text: + "Look for a field labeled 'Coupon Code' or 'Promo Code' during checkout.", + textColor: Color(0xFF656565), + ), + const CustomBulletPoints( + text: + "Enter your coupon code exactly as it appears, including any special characters.", + textColor: Color(0xFF656565), + ), + + SizedBox(height: 24.h), + + Container( + width: double.infinity, + height: 48.h, + padding: EdgeInsets.symmetric( + vertical: 12.h, + horizontal: 24.w, + ), + decoration: BoxDecoration( + color: const Color(0xFFFEE7E7), + borderRadius: BorderRadius.circular(10.r), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ Text( - "Aster Hotels", + offer.offerCode, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, - color: Colors.white, + letterSpacing: 0.5, + ), + ), + GestureDetector( + onTap: () { + Clipboard.setData( + ClipboardData(text: offer.offerCode), + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text("Coupon code copied!"), + duration: Duration(seconds: 1), + ), + ); + }, + child: Icon( + Icons.copy_outlined, + color: const Color(0xFF464646), + size: 20.sp, ), ), ], ), - ], - ), - ), - ), - ), - - Positioned( - bottom: 31.h, - left: 12.w, - child: Text( - "Aster \nHotels", - style: TextStyle( - color: Colors.white, - fontSize: 48.sp, - fontWeight: FontWeight.w500, - height: 1.2, - ), - ), - ), - - Positioned( - bottom: 31.h, - right: 17.w, - child: GestureDetector( - onTap: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => const ShareBottomSheet(), - ); - }, - child: Container( - height: 36.h, - width: 36.w, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20.r), - ), - child: Center( - child: Icon( - Icons.share_sharp, - color: Colors.black, - size: 18.sp, - ), - ), - ), - ), - ), - ], - ), - Padding( - padding: EdgeInsets.symmetric( - horizontal: 20.0.w, - vertical: 30.5.h, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "About Aster Hotels", - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - ), - ), - SizedBox(height: 8.h), - Text( - "20% Off on dining and drinks on purchase upto \$500 T&Cs* apply", - style: TextStyle( - fontSize: 14.sp, - color: Colors.black, - fontWeight: FontWeight.w400, - ), - ), - SizedBox(height: 10.h), - Text( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " - "Convallis condimentum morbi non egestas enim amet sagittis. " - "Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac. " - "Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non...", - style: TextStyle( - fontSize: 14.sp, - height: 1.4, - color: const Color(0xFF656565), - ), - ), - SizedBox(height: 40.h), - - // How to make booking - Text( - "How to make a booking?", - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w400, - ), - ), - SizedBox(height: 16.h), - - CustomBulletPoints( - text: - "Check the expiration date of your coupon to ensure it's still valid.", - textColor: Color(0xFF656565), - ), - CustomBulletPoints( - text: - "Visit the store or website where the coupon can be redeemed.", - textColor: Color(0xFF656565), - ), - CustomBulletPoints( - text: - "If shopping online, add items to your cart and proceed to checkout.", - textColor: Color(0xFF656565), - ), - CustomBulletPoints( - text: - "Look for a field labeled 'Coupon Code' or 'Promo Code' during checkout.", - textColor: Color(0xFF656565), - ), - CustomBulletPoints( - text: - "Enter your coupon code exactly as it appears, including any special characters.", - textColor: Color(0xFF656565), - ), - SizedBox(height: 24.h), - - // Coupon Box - Container( - width: double.infinity, - height: 48.h, - padding: EdgeInsets.symmetric( - vertical: 12.h, - horizontal: 24.w, - ), - decoration: BoxDecoration( - color: const Color(0xFFFEE7E7), - borderRadius: BorderRadius.circular(10.r), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "AFJIJFJ500", - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - GestureDetector( - onTap: () { - Clipboard.setData( - const ClipboardData(text: "AFJIJFJ500"), - ); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Coupon code copied!"), - duration: Duration(seconds: 1), - ), - ); - }, - child: Icon( - Icons.copy_outlined, - color: Color(0xFF464646), - size: 20.sp, - ), ), ], ), ), ], ), - ), - ], - ), + ); + } + + return const SizedBox(); + }, ), ), ); diff --git a/lib/offer_pass_detail/repository/offers_details_repository.dart b/lib/offer_pass_detail/repository/offers_details_repository.dart new file mode 100644 index 0000000..b8eaf5d --- /dev/null +++ b/lib/offer_pass_detail/repository/offers_details_repository.dart @@ -0,0 +1,18 @@ +import '../models/offer_details_model.dart'; +import '../../networkApiServices/network_api_services.dart'; +import '../../networkApiServices/api_urls.dart'; + +class OffersDetailsRepository { + final NetworkApiService _apiService = NetworkApiService(); + + /// Fetch offer details by offerId + Future fetchOfferDetails({ + required int offerId, + }) async { + final response = await _apiService.getApi( + url: '${ApiUrls.offersDetails}/$offerId', + ); + + return OfferDetailsModel.fromJson(response.data); + } +} diff --git a/lib/profile/bloc/profile/profile_bloc.dart b/lib/profile/bloc/profile/profile_bloc.dart index 78515a9..078b9b2 100644 --- a/lib/profile/bloc/profile/profile_bloc.dart +++ b/lib/profile/bloc/profile/profile_bloc.dart @@ -21,53 +21,86 @@ class ProfileBloc extends Bloc { Emitter emit, ) async { try { + if (kDebugMode) { + print('🔄 [BLOC] FetchProfileEvent received for userId: ${event.userId}'); + } + emit(const ProfileLoading()); final profile = await _profileRepository.fetchUserProfile(); - emit(ProfileLoaded(profile: profile)); - if (kDebugMode) { - print( - '✅ Profile fetched successfully: ${profile.firstName} ${profile.lastName}', - ); + print('✅ [BLOC] Profile fetched successfully: ${profile.firstName} ${profile.lastName}'); + print('✅ [BLOC] Profile Image URL: ${profile.profileImage}'); } + + emit(ProfileLoaded(profile: profile)); } catch (e) { final errorMessage = e.toString(); - emit(ProfileError(message: errorMessage)); if (kDebugMode) { - print('❌ Error fetching profile: $errorMessage'); + print('❌ [BLOC] Error fetching profile: $errorMessage'); + print('❌ [BLOC] Error type: ${e.runtimeType}'); } + + emit(ProfileError(message: errorMessage)); } } /// Handle updating user profile + /// ⭐ UPDATED: Now passes File to repository Future _onUpdateProfile( UpdateProfileEvent event, Emitter emit, ) async { try { + if (kDebugMode) { + print('🔄 [BLOC] UpdateProfileEvent received'); + print('🔄 [BLOC] User ID: ${event.userId}'); + print('🔄 [BLOC] First Name: ${event.firstName}'); + print('🔄 [BLOC] Last Name: ${event.lastName}'); + print('🔄 [BLOC] Mobile: ${event.mobileNumber}'); + print('🔄 [BLOC] Address1: ${event.address1}'); + print('🔄 [BLOC] Address2: ${event.address2}'); + + if (event.profileImageFile != null) { + print('🔄 [BLOC] ✅ Profile Image File Present in Event'); + print('🔄 [BLOC] File Path: ${event.profileImageFile!.path}'); + final fileSize = await event.profileImageFile!.length(); + print('🔄 [BLOC] File Size: ${(fileSize / 1024).toStringAsFixed(2)} KB'); + } else { + print('🔄 [BLOC] ⚠️ Profile Image File is NULL in Event'); + } + + print('🔄 [BLOC] Calling toJson()...'); + final jsonData = event.toJson(); + print('🔄 [BLOC] JSON Data: $jsonData'); + } + emit(const ProfileUpdating()); + // ⭐ Pass both data and file to repository final updatedProfile = await _profileRepository.updateUserProfile( data: event.toJson(), + profileImageFile: event.profileImageFile, // ⭐ NEW: Pass File ); - emit(ProfileUpdated(profile: updatedProfile)); - if (kDebugMode) { - print( - '✅ Profile updated successfully: ${updatedProfile.firstName} ${updatedProfile.lastName}', - ); + print('✅ [BLOC] Profile updated successfully: ${updatedProfile.firstName} ${updatedProfile.lastName}'); + print('✅ [BLOC] Updated Profile Image: ${updatedProfile.profileImage}'); } + + emit(ProfileUpdated(profile: updatedProfile)); } catch (e) { final errorMessage = e.toString(); - emit(ProfileError(message: errorMessage)); if (kDebugMode) { - print('❌ Error updating profile: $errorMessage'); + print('❌ [BLOC] Error updating profile: $errorMessage'); + print('❌ [BLOC] Error type: ${e.runtimeType}'); + print('❌ [BLOC] Stack trace: ${StackTrace.current}'); } + + emit(ProfileError(message: errorMessage)); } } @@ -76,6 +109,9 @@ class ProfileBloc extends Bloc { ResetProfileEvent event, Emitter emit, ) { + if (kDebugMode) { + print('🔄 [BLOC] ResetProfileEvent received'); + } emit(const ProfileInitial()); } -} +} \ No newline at end of file diff --git a/lib/profile/bloc/profile/profile_event.dart b/lib/profile/bloc/profile/profile_event.dart index 12dc566..6e0c04b 100644 --- a/lib/profile/bloc/profile/profile_event.dart +++ b/lib/profile/bloc/profile/profile_event.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:equatable/equatable.dart'; abstract class ProfileEvent extends Equatable { @@ -18,6 +19,7 @@ class FetchProfileEvent extends ProfileEvent { } /// Event to update user profile +/// ⭐ UPDATED: Now accepts File instead of base64 string class UpdateProfileEvent extends ProfileEvent { final int userId; final String firstName; @@ -25,6 +27,7 @@ class UpdateProfileEvent extends ProfileEvent { final String mobileNumber; final String? address1; final String? address2; + final File? profileImageFile; // ⭐ CHANGED: File instead of String const UpdateProfileEvent({ required this.userId, @@ -33,6 +36,7 @@ class UpdateProfileEvent extends ProfileEvent { required this.mobileNumber, this.address1, this.address2, + this.profileImageFile, // ⭐ CHANGED }); @override @@ -43,6 +47,7 @@ class UpdateProfileEvent extends ProfileEvent { mobileNumber, address1, address2, + profileImageFile, // ⭐ CHANGED ]; Map toJson() { @@ -52,6 +57,7 @@ class UpdateProfileEvent extends ProfileEvent { 'mobileNumber': mobileNumber, if (address1 != null && address1!.isNotEmpty) 'address1': address1, if (address2 != null && address2!.isNotEmpty) 'address2': address2, + // ⭐ Note: profileImageFile is handled separately in repository }; } } diff --git a/lib/profile/models/profile_model.dart b/lib/profile/models/profile_model.dart index fd70a05..13c53bc 100644 --- a/lib/profile/models/profile_model.dart +++ b/lib/profile/models/profile_model.dart @@ -6,6 +6,7 @@ class ProfileModel { final String emailAddress; final String isdCode; final String mobileNumber; + final String? profileImage; // ✅ NEW final String? address1; final String? address2; final String? cityName; @@ -27,6 +28,7 @@ class ProfileModel { required this.emailAddress, required this.isdCode, required this.mobileNumber, + this.profileImage, this.address1, this.address2, this.cityName, @@ -50,6 +52,7 @@ class ProfileModel { emailAddress: json['emailAddress'] ?? 'N/A', isdCode: json['isdCode'] ?? 'N/A', mobileNumber: json['mobileNumber'] ?? 'N/A', + profileImage: json['profileImage'], // ✅ added address1: json['address1'], address2: json['address2'], cityName: json['cityName'], @@ -57,10 +60,10 @@ class ProfileModel { stateName: json['stateName'], country: json['country'], timezone: json['timezone'], - lastLogin: json['lastLogin'], + lastLogin: json['lastLogin']?.toString(), isActive: json['isActive'] ?? false, - createdAt: json['createdAt'] ?? 'N/A', - updatedAt: json['updatedAt'] ?? 'N/A', + createdAt: json['createdAt'] ?? '', + updatedAt: json['updatedAt'] ?? '', role: json['role'] != null ? RoleModel.fromJson(json['role']) : null, ); } @@ -74,6 +77,7 @@ class ProfileModel { 'emailAddress': emailAddress, 'isdCode': isdCode, 'mobileNumber': mobileNumber, + 'profileImage': profileImage, 'address1': address1, 'address2': address2, 'cityName': cityName, @@ -88,50 +92,6 @@ class ProfileModel { if (role != null) 'role': role!.toJson(), }; } - - ProfileModel copyWith({ - int? id, - String? firstName, - String? lastName, - int? roleXid, - String? emailAddress, - String? isdCode, - String? mobileNumber, - String? address1, - String? address2, - String? cityName, - String? zipCode, - String? stateName, - String? country, - String? timezone, - String? lastLogin, - bool? isActive, - String? createdAt, - String? updatedAt, - RoleModel? role, - }) { - return ProfileModel( - id: id ?? this.id, - firstName: firstName ?? this.firstName, - lastName: lastName ?? this.lastName, - roleXid: roleXid ?? this.roleXid, - emailAddress: emailAddress ?? this.emailAddress, - isdCode: isdCode ?? this.isdCode, - mobileNumber: mobileNumber ?? this.mobileNumber, - address1: address1 ?? this.address1, - address2: address2 ?? this.address2, - cityName: cityName ?? this.cityName, - zipCode: zipCode ?? this.zipCode, - stateName: stateName ?? this.stateName, - country: country ?? this.country, - timezone: timezone ?? this.timezone, - lastLogin: lastLogin ?? this.lastLogin, - isActive: isActive ?? this.isActive, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - role: role ?? this.role, - ); - } } class RoleModel { diff --git a/lib/profile/repository/profile_repository.dart b/lib/profile/repository/profile_repository.dart index a0321a6..eb143d6 100644 --- a/lib/profile/repository/profile_repository.dart +++ b/lib/profile/repository/profile_repository.dart @@ -1,3 +1,6 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import '../models/profile_model.dart'; import '../../networkApiServices/network_api_services.dart'; import '../../networkApiServices/api_urls.dart'; @@ -10,24 +13,113 @@ class ProfileRepository { Future fetchUserProfile() async { final int? userId = await LocalPreference.getUserId(); + if (kDebugMode) { + print('📥 [FETCH PROFILE] User ID: $userId'); + print('📥 [FETCH PROFILE] URL: ${ApiUrls.userProfile}/$userId'); + } + final response = await _apiService.getApi( url: '${ApiUrls.userProfile}/$userId', ); + if (kDebugMode) { + print('📥 [FETCH PROFILE] Response: ${response.data}'); + print('📥 [FETCH PROFILE] Profile Image: ${response.data['profileImage']}'); + } + return ProfileModel.fromJson(response.data); } /// Update user profile (userId from local storage) + /// ⭐ FIXED: Now uses multipart/form-data for file upload Future updateUserProfile({ required Map data, + File? profileImageFile, // ⭐ NEW: Accept File instead of base64 }) async { final int? userId = await LocalPreference.getUserId(); + if (kDebugMode) { + print('📤 [UPDATE PROFILE] User ID: $userId'); + print('📤 [UPDATE PROFILE] URL: ${ApiUrls.userProfile}/$userId'); + print('📤 [UPDATE PROFILE] Data Keys: ${data.keys.toList()}'); + print('📤 [UPDATE PROFILE] First Name: ${data['firstName']}'); + print('📤 [UPDATE PROFILE] Last Name: ${data['lastName']}'); + print('📤 [UPDATE PROFILE] Mobile: ${data['mobileNumber']}'); + print('📤 [UPDATE PROFILE] Address1: ${data['address1']}'); + print('📤 [UPDATE PROFILE] Address2: ${data['address2']}'); + print('📤 [UPDATE PROFILE] Profile Image File: ${profileImageFile?.path}'); + } + + // ⭐ Create FormData for multipart/form-data upload + final formData = FormData(); + + // Add text fields + formData.fields.addAll([ + MapEntry('firstName', data['firstName']), + MapEntry('lastName', data['lastName']), + MapEntry('mobileNumber', data['mobileNumber']), + if (data['address1'] != null && data['address1'].toString().isNotEmpty) + MapEntry('address1', data['address1']), + if (data['address2'] != null && data['address2'].toString().isNotEmpty) + MapEntry('address2', data['address2']), + ]); + + // ⭐ Add profile image file if provided + if (profileImageFile != null) { + final fileName = profileImageFile.path.split('/').last; + formData.files.add( + MapEntry( + 'profileImage', + await MultipartFile.fromFile( + profileImageFile.path, + filename: fileName, + ), + ), + ); + + if (kDebugMode) { + print('📤 [UPDATE PROFILE] ✅ Profile Image File Added'); + print('📤 [UPDATE PROFILE] File Name: $fileName'); + print('📤 [UPDATE PROFILE] File Path: ${profileImageFile.path}'); + final fileSize = await profileImageFile.length(); + print('📤 [UPDATE PROFILE] File Size: ${(fileSize / 1024).toStringAsFixed(2)} KB'); + } + } else { + if (kDebugMode) { + print('📤 [UPDATE PROFILE] ⚠️ No profile image file provided'); + } + } + + // ⭐ Send as multipart/form-data final response = await _apiService.putApi( url: '${ApiUrls.userProfile}/$userId', - data: data, + data: formData, ); - return ProfileModel.fromJson(response.data); + if (kDebugMode) { + print('📤 [UPDATE PROFILE] ✅ Response Status: Success'); + print('📤 [UPDATE PROFILE] Full Response: ${response.data}'); + + // Check if response has nested 'user' object + if (response.data.containsKey('user')) { + print('📤 [UPDATE PROFILE] ✅ Response has nested "user" object'); + print('📤 [UPDATE PROFILE] User Data: ${response.data['user']}'); + print('📤 [UPDATE PROFILE] Updated Profile Image: ${response.data['user']['profileImage']}'); + } else { + print('📤 [UPDATE PROFILE] Response structure: ${response.data.keys.toList()}'); + print('📤 [UPDATE PROFILE] Updated Profile Image: ${response.data['profileImage']}'); + } + } + + // Extract user data from nested response + final userData = response.data.containsKey('user') + ? response.data['user'] + : response.data; + + if (kDebugMode) { + print('📤 [UPDATE PROFILE] Parsing ProfileModel from: $userData'); + } + + return ProfileModel.fromJson(userData); } -} +} \ No newline at end of file diff --git a/lib/edit_profile/edit_profile_view.dart b/lib/profile/view/edit_profile/edit_profile_view.dart similarity index 60% rename from lib/edit_profile/edit_profile_view.dart rename to lib/profile/view/edit_profile/edit_profile_view.dart index f6b9d2a..8630ba9 100644 --- a/lib/edit_profile/edit_profile_view.dart +++ b/lib/profile/view/edit_profile/edit_profile_view.dart @@ -1,16 +1,20 @@ +import 'dart:io'; import 'package:citycards_customer/common_packages/app_bar.dart'; import 'package:citycards_customer/common_packages/back_widget.dart'; import 'package:citycards_customer/common_packages/custom_textfield.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:citycards_customer/common_packages/custom_text.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; -import '../../localPreference/local_preference.dart'; -import '../profile/bloc/profile/profile_bloc.dart'; -import '../profile/bloc/profile/profile_event.dart'; -import '../profile/bloc/profile/profile_state.dart'; -import '../profile/models/profile_model.dart'; +import '../../../localPreference/local_preference.dart'; +import '../../../networkApiServices/api_urls.dart'; +import '../../bloc/profile/profile_bloc.dart'; +import '../../bloc/profile/profile_event.dart'; +import '../../bloc/profile/profile_state.dart'; +import '../../models/profile_model.dart'; class EditProfilePage extends StatefulWidget { const EditProfilePage({super.key}); @@ -28,6 +32,11 @@ class _EditProfilePageState extends State { final TextEditingController address2Controller = TextEditingController(); final _formKey = GlobalKey(); + final ImagePicker _picker = ImagePicker(); + + // Profile image variables + File? _selectedImageFile; // ⭐ Only need File now, no base64 + String? _currentProfileImageUrl; @override void initState() { @@ -36,6 +45,10 @@ class _EditProfilePageState extends State { } Future _fetchProfile() async { + if (kDebugMode) { + print('🔵 [EDIT PROFILE] Fetching profile...'); + } + final userId = await LocalPreference.getUserId(); if (userId != null) { context.read().add(FetchProfileEvent(userId: userId)); @@ -43,18 +56,226 @@ class _EditProfilePageState extends State { } void _populateFields(ProfileModel profile) { + if (kDebugMode) { + print('🔵 [EDIT PROFILE] Populating fields'); + print('🔵 [EDIT PROFILE] Profile Image from API: ${profile.profileImage}'); + } + firstNameController.text = profile.firstName; lastNameController.text = profile.lastName; phoneController.text = profile.mobileNumber; address1Controller.text = profile.address1 ?? ''; address2Controller.text = profile.address2 ?? ''; + + // Store current profile image URL if exists + if (profile.profileImage != null && profile.profileImage!.isNotEmpty) { + setState(() { + _currentProfileImageUrl = profile.profileImage; + }); + + if (kDebugMode) { + print('🔵 [EDIT PROFILE] ✅ Current profile image URL set: $_currentProfileImageUrl'); + } + } + } + + /// Helper method to get full image URL + String _getFullImageUrl(String? imageUrl) { + if (imageUrl == null || imageUrl.isEmpty) return ''; + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + return imageUrl; + } + + final baseUrl = ApiUrls.userProfile.split('/mobile')[0]; + final fullUrl = '$baseUrl$imageUrl'; + + if (kDebugMode) { + print('🔵 [EDIT PROFILE] Full URL: $fullUrl'); + } + + return fullUrl; + } + + Future _showImageSourceDialog() async { + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), + ), + builder: (BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(vertical: 20.h, horizontal: 16.w), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Select Image Source', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 20.h), + ListTile( + leading: Icon( + Icons.camera_alt, + color: Color(0xFFF95F62), + size: 28.sp, + ), + title: Text( + 'Camera', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + _pickImage(ImageSource.camera); + }, + ), + Divider(), + ListTile( + leading: Icon( + Icons.photo_library, + color: Color(0xFFF95F62), + size: 28.sp, + ), + title: Text( + 'Gallery', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(context); + _pickImage(ImageSource.gallery); + }, + ), + SizedBox(height: 10.h), + ], + ), + ); + }, + ); + } + + Future _pickImage(ImageSource source) async { + try { + if (kDebugMode) { + print('🔵 [EDIT PROFILE] Picking image from: $source'); + } + + final XFile? pickedFile = await _picker.pickImage( + source: source, + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 85, + ); + + if (pickedFile != null) { + final File imageFile = File(pickedFile.path); + final fileSize = await imageFile.length(); + + if (kDebugMode) { + print('🔵 [EDIT PROFILE] ✅ Image picked: ${pickedFile.path}'); + print('🔵 [EDIT PROFILE] File size: ${(fileSize / 1024).toStringAsFixed(2)} KB'); + } + + setState(() { + _selectedImageFile = imageFile; // ⭐ Just store the File + }); + } + } catch (e) { + if (kDebugMode) { + print('❌ [EDIT PROFILE] Error picking image: $e'); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to pick image: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + Widget _buildProfileImageWidget() { + return Center( + child: Stack( + children: [ + Container( + width: 76.w, + height: 76.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFFCE4E5), + ), + child: ClipOval( + child: _selectedImageFile != null + ? Image.file( + _selectedImageFile!, + fit: BoxFit.cover, + ) + : _currentProfileImageUrl != null && _currentProfileImageUrl!.isNotEmpty + ? Image.network( + _getFullImageUrl(_currentProfileImageUrl), + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + color: Color(0xFFF95F62), + strokeWidth: 2, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + if (kDebugMode) { + print('❌ [EDIT PROFILE] Error loading image: $error'); + } + return Padding( + padding: EdgeInsets.all(16.w), + child: Image.asset( + 'assets/images/profile_default_img.png', + fit: BoxFit.contain, + ), + ); + }, + ) + : Padding( + padding: EdgeInsets.all(16.w), + child: Image.asset( + 'assets/images/profile_default_img.png', + fit: BoxFit.contain, + ), + ), + ), + ), + ], + ), + ); } void _saveProfile() async { if (_formKey.currentState?.validate() ?? false) { final userId = await LocalPreference.getUserId(); if (userId != null) { - // No setState here - BLoC will handle the state + if (kDebugMode) { + print('🔵 [EDIT PROFILE] Saving profile...'); + if (_selectedImageFile != null) { + print('🔵 [EDIT PROFILE] ✅ New image will be uploaded'); + print('🔵 [EDIT PROFILE] File path: ${_selectedImageFile!.path}'); + } else { + print('🔵 [EDIT PROFILE] ⚠️ No new image selected'); + } + } + context.read().add( UpdateProfileEvent( userId: userId, @@ -67,6 +288,7 @@ class _EditProfilePageState extends State { address2: address2Controller.text.trim().isEmpty ? null : address2Controller.text.trim(), + profileImageFile: _selectedImageFile, // ⭐ Pass File directly ), ); } @@ -90,6 +312,10 @@ class _EditProfilePageState extends State { if (state is ProfileLoaded) { _populateFields(state.profile); } else if (state is ProfileUpdated) { + if (kDebugMode) { + print('✅ [EDIT PROFILE] Profile updated successfully!'); + } + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), @@ -97,7 +323,6 @@ class _EditProfilePageState extends State { duration: const Duration(seconds: 2), ), ); - // Return true to indicate successful update Navigator.pop(context, true); } else if (state is ProfileError) { ScaffoldMessenger.of(context).showSnackBar( @@ -110,11 +335,9 @@ class _EditProfilePageState extends State { } }, builder: (context, state) { - // Determine loading state from BLoC final isLoading = state is ProfileLoading || state is ProfileUpdating; final isInitialLoading = state is ProfileLoading; - // Show loading on initial fetch if (isInitialLoading) { return Scaffold( backgroundColor: Colors.white, @@ -132,24 +355,36 @@ class _EditProfilePageState extends State { child: Stack( children: [ SingleChildScrollView( - padding: - EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Header CommonAppBar( isWhiteLogo: false, isProfilePage: true, showDivider: true, ), - - // Back + title backWidget(context, "Edit Profile", Colors.black), SizedBox(height: 33.h), + // Profile Picture + _buildProfileImageWidget(), + SizedBox(height: 12.h), + GestureDetector( + onTap: isLoading ? null : _showImageSourceDialog, + child: Text( + 'Change Profile Picture', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: Color(0xFFF95F62), + ), + ), + ), + SizedBox(height: 33.h), + // Personal Details Align( alignment: Alignment.centerLeft, @@ -161,7 +396,7 @@ class _EditProfilePageState extends State { ), SizedBox(height: 16.h), - // First Name + // Form fields... Padding( padding: EdgeInsets.symmetric(horizontal: 12.w), child: CustomTextField( @@ -178,7 +413,6 @@ class _EditProfilePageState extends State { ), ), - // Last Name Padding( padding: EdgeInsets.symmetric(horizontal: 12.w), child: CustomTextField( @@ -195,7 +429,6 @@ class _EditProfilePageState extends State { ), ), - // Phone Number Padding( padding: EdgeInsets.symmetric(horizontal: 12.w), child: CustomTextField( @@ -214,7 +447,6 @@ class _EditProfilePageState extends State { SizedBox(height: 20.h), - // Location Details Align( alignment: Alignment.centerLeft, child: CustomText( @@ -225,7 +457,6 @@ class _EditProfilePageState extends State { ), SizedBox(height: 16.h), - // Address 1 Padding( padding: EdgeInsets.symmetric(horizontal: 12.0.w), child: CustomTextField( @@ -236,7 +467,6 @@ class _EditProfilePageState extends State { ), ), - // Address 2 Padding( padding: EdgeInsets.symmetric(horizontal: 12.0.w), child: CustomTextField( @@ -257,18 +487,13 @@ class _EditProfilePageState extends State { child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFFF95F62), - side: const BorderSide( - color: Colors.transparent), + side: const BorderSide(color: Colors.transparent), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(38.r), ), padding: EdgeInsets.symmetric(vertical: 12.h), ), - onPressed: isLoading - ? null - : () { - Navigator.pop(context); - }, + onPressed: isLoading ? null : () => Navigator.pop(context), child: Text( "Cancel", style: TextStyle( @@ -316,7 +541,6 @@ class _EditProfilePageState extends State { ), ), - // Loading overlay when saving if (state is ProfileUpdating) Container( color: Colors.black.withOpacity(0.3), diff --git a/lib/profile/view/profile_page_view.dart b/lib/profile/view/profile_page_view.dart index ed47635..66e28c7 100644 --- a/lib/profile/view/profile_page_view.dart +++ b/lib/profile/view/profile_page_view.dart @@ -4,6 +4,7 @@ import 'package:citycards_customer/common_packages/back_widget.dart'; import 'package:citycards_customer/common_packages/custom_text.dart'; import 'package:citycards_customer/common_packages/language_selection_bottomsheet.dart'; import 'package:citycards_customer/core/route_constants.dart'; +import 'package:citycards_customer/networkApiServices/api_urls.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -311,44 +312,83 @@ class _ProfilePageState extends State { // Logged In User UI with dynamic data from API Widget _buildLoggedInUI(BuildContext context, ProfileModel? profile) { - // Construct full name + /// ---------- Full name ---------- String fullName = 'User'; if (profile != null) { - fullName = '${profile.firstName} ${profile.lastName}'.trim(); - if (fullName.isEmpty) { - fullName = 'User'; + fullName = '${profile.firstName ?? ''} ${profile.lastName ?? ''}'.trim(); + if (fullName.isEmpty) fullName = 'User'; + } + + /// ---------- Location ---------- + String location = 'Not specified'; + if (profile != null) { + final parts = []; + if (profile.address1?.isNotEmpty == true) { + parts.add(profile.address1!); + } + if (profile.address2?.isNotEmpty == true) { + parts.add(profile.address2!); + } + if (parts.isNotEmpty) { + location = parts.join(', '); } } - // Construct location - String location = 'Not specified'; - if (profile != null) { - List locationParts = []; - - if (profile.address1 != null && profile.address1!.isNotEmpty) { - locationParts.add(profile.address1!); - } - if (profile.address2 != null && profile.address2!.isNotEmpty) { - locationParts.add(profile.address2!); - } - - if (locationParts.isNotEmpty) { - location = locationParts.join(', '); - } + /// ---------- Profile Image URL ---------- + String? profileImageUrl; + if (profile?.profileImage?.isNotEmpty == true) { + profileImageUrl = ApiUrls.baseUrl + profile!.profileImage!; } return Column( children: [ - // Profile Image and Name + /// ================= Profile Row ================= Row( children: [ - CircleAvatar( - radius: 38.r, - backgroundImage: AssetImage( - "assets/images/profile_img.png", + /// -------- Profile Image (NO ZOOM PLACEHOLDER) -------- + SizedBox( + width: 76.w, + height: 76.w, + child: ClipOval( + child: Container( + color: const Color(0xFFFCE4E5), + child: profileImageUrl != null + ? Image.network( + profileImageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFFF95F62), + ), + ); + }, + errorBuilder: (_, __, ___) { + return Padding( + padding: EdgeInsets.all(16.w), + child: Image.asset( + 'assets/images/profile_default_img.png', + fit: BoxFit.contain, + ), + ); + }, + ) + : Padding( + padding: EdgeInsets.all(16.w), + child: Image.asset( + 'assets/images/profile_default_img.png', + fit: BoxFit.contain, // ✅ NO ZOOM + ), + ), + ), ), ), + SizedBox(width: 16.w), + + /// -------- Name & Location -------- Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -367,7 +407,7 @@ class _ProfilePageState extends State { children: [ Icon( Icons.location_on_sharp, - color: Color(0xFF8E8E8E), + color: const Color(0xFF8E8E8E), size: 14.sp, ), SizedBox(width: 4.w), @@ -376,7 +416,7 @@ class _ProfilePageState extends State { location, style: TextStyle( fontSize: 12.sp, - color: Color(0xFF8E8E8E), + color: const Color(0xFF8E8E8E), ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -392,7 +432,7 @@ class _ProfilePageState extends State { SizedBox(height: 30.h), - // Account Settings Section + /// ================= Account Settings ================= Align( alignment: Alignment.centerLeft, child: CustomText( @@ -401,6 +441,7 @@ class _ProfilePageState extends State { size: 18.sp, ), ), + SizedBox(height: 10.h), _buildListTile( @@ -412,7 +453,6 @@ class _ProfilePageState extends State { RouteConstants.editProfile, ); - // Refresh profile if edit was successful if (result == true) { final userId = await LocalPreference.getUserId(); if (userId != null) { @@ -423,6 +463,7 @@ class _ProfilePageState extends State { } }, ), + _buildListTile( icon: "assets/icons/change_language.png", title: 'Change language', @@ -442,6 +483,7 @@ class _ProfilePageState extends State { ); }, ), + SizedBox(height: 24.h), ], ); diff --git a/lib/search_offers/bloc/offers_bloc.dart b/lib/search_offers/bloc/offers_bloc.dart new file mode 100644 index 0000000..8270ac2 --- /dev/null +++ b/lib/search_offers/bloc/offers_bloc.dart @@ -0,0 +1,66 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../model/offers_model.dart'; +import '../repository/offers_repository.dart'; +import 'offers_event.dart'; +import 'offers_state.dart'; + +class OffersBloc extends Bloc { + final OffersRepository repository; + + List _allOffers = []; + + OffersBloc(this.repository) : super(OffersInitial()) { + on(_onLoadOffers); + on(_onSearchOffers); + } + + Future _onLoadOffers( + LoadOffers event, + Emitter emit, + ) async { + emit(OffersLoading()); + + try { + final response = await repository.fetchOffers( + categoryXid: event.categoryXid, + ); + + _allOffers = response.offers; + + emit( + OffersLoaded( + offers: response.offers, + categories: response.categories, + ), + ); + } catch (e) { + emit(OffersError(e.toString())); + } + } + + void _onSearchOffers( + SearchOffers event, + Emitter emit, + ) { + final filtered = _allOffers + .where( + (offer) => + offer.title + .toLowerCase() + .contains(event.query.toLowerCase()) || + offer.description + .toLowerCase() + .contains(event.query.toLowerCase()), + ) + .toList(); + + if (state is OffersLoaded) { + emit( + OffersLoaded( + offers: filtered, + categories: (state as OffersLoaded).categories, + ), + ); + } + } +} diff --git a/lib/search_offers/bloc/offers_event.dart b/lib/search_offers/bloc/offers_event.dart new file mode 100644 index 0000000..384d1f5 --- /dev/null +++ b/lib/search_offers/bloc/offers_event.dart @@ -0,0 +1,11 @@ +abstract class OffersEvent {} + +class LoadOffers extends OffersEvent { + final int? categoryXid; + LoadOffers({this.categoryXid}); +} + +class SearchOffers extends OffersEvent { + final String query; + SearchOffers(this.query); +} diff --git a/lib/search_offers/bloc/offers_state.dart b/lib/search_offers/bloc/offers_state.dart new file mode 100644 index 0000000..de1561c --- /dev/null +++ b/lib/search_offers/bloc/offers_state.dart @@ -0,0 +1,22 @@ +import '../model/offers_model.dart'; + +abstract class OffersState {} + +class OffersInitial extends OffersState {} + +class OffersLoading extends OffersState {} + +class OffersLoaded extends OffersState { + final List offers; + final List categories; + + OffersLoaded({ + required this.offers, + required this.categories, + }); +} + +class OffersError extends OffersState { + final String message; + OffersError(this.message); +} diff --git a/lib/search_offers/bloc/search_offers_listing_bloc.dart b/lib/search_offers/bloc/search_offers_listing_bloc.dart index 2b7e77b..ce2dd93 100644 --- a/lib/search_offers/bloc/search_offers_listing_bloc.dart +++ b/lib/search_offers/bloc/search_offers_listing_bloc.dart @@ -1,75 +1,75 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; - -// ----- Events ----- -abstract class OffersEvent {} - -class LoadOffers extends OffersEvent {} - -class SearchOffers extends OffersEvent { - final String query; - SearchOffers(this.query); -} - -// ----- State ----- -class OffersState { - final List> offers; - const OffersState(this.offers); -} - -// ----- Bloc ----- -class OffersBloc extends Bloc { - OffersBloc() : super(const OffersState([])) { - on(_onLoadOffers); - on(_onSearchOffers); - } - - final List> _allOffers = [ - { - "image": "assets/images/aa1.png", - "title": "Astor Hotels Ultra Deluxe", - "description": "15% Discount on all treatments for first-time clients" - }, - { - "image": "assets/images/aa2.png", - "title": "Green Valley Spa Lux", - "description": "20% off on spa memberships and treatments" - }, - { - "image": "assets/images/aa3.png", - "title": "Ocean Breeze Resort", - "description": "Complimentary breakfast with every booking for first-time guests" - }, - { - "image": "assets/images/aa4.png", - "title": "Mountain Retreat of Light", - "description": "10% Discount on group bookings of 5 or more guests" - }, - { - "image": "assets/images/card_banner.png", - "title": "Mountain View Retreat", - "description": "Free hiking gear rental for all visitors during their stay" - }, - { - "image": "assets/images/city_germany.jpg", - "title": "Sunny Shores Hotel", - "description": "10% Discount on group bookings of 5 or more guests" - }, - - - ]; - - void _onLoadOffers(event,emit) { - emit(OffersState(_allOffers)); - } - - void _onSearchOffers(event,emit) { - final filtered = _allOffers - .where((offer) => - offer["title"]!.toLowerCase().contains(event.query.toLowerCase()) || - offer["description"]! - .toLowerCase() - .contains(event.query.toLowerCase())) - .toList(); - emit(OffersState(filtered)); - } -} +// import 'package:flutter_bloc/flutter_bloc.dart'; +// +// // ----- Events ----- +// abstract class OffersEvent {} +// +// class LoadOffers extends OffersEvent {} +// +// class SearchOffers extends OffersEvent { +// final String query; +// SearchOffers(this.query); +// } +// +// // ----- State ----- +// class OffersState { +// final List> offers; +// const OffersState(this.offers); +// } +// +// // ----- Bloc ----- +// class OffersBloc extends Bloc { +// OffersBloc() : super(const OffersState([])) { +// on(_onLoadOffers); +// on(_onSearchOffers); +// } +// +// final List> _allOffers = [ +// { +// "image": "assets/images/aa1.png", +// "title": "Astor Hotels Ultra Deluxe", +// "description": "15% Discount on all treatments for first-time clients" +// }, +// { +// "image": "assets/images/aa2.png", +// "title": "Green Valley Spa Lux", +// "description": "20% off on spa memberships and treatments" +// }, +// { +// "image": "assets/images/aa3.png", +// "title": "Ocean Breeze Resort", +// "description": "Complimentary breakfast with every booking for first-time guests" +// }, +// { +// "image": "assets/images/aa4.png", +// "title": "Mountain Retreat of Light", +// "description": "10% Discount on group bookings of 5 or more guests" +// }, +// { +// "image": "assets/images/card_banner.png", +// "title": "Mountain View Retreat", +// "description": "Free hiking gear rental for all visitors during their stay" +// }, +// { +// "image": "assets/images/city_germany.jpg", +// "title": "Sunny Shores Hotel", +// "description": "10% Discount on group bookings of 5 or more guests" +// }, +// +// +// ]; +// +// void _onLoadOffers(event,emit) { +// emit(OffersState(_allOffers)); +// } +// +// void _onSearchOffers(event,emit) { +// final filtered = _allOffers +// .where((offer) => +// offer["title"]!.toLowerCase().contains(event.query.toLowerCase()) || +// offer["description"]! +// .toLowerCase() +// .contains(event.query.toLowerCase())) +// .toList(); +// emit(OffersState(filtered)); +// } +// } diff --git a/lib/search_offers/model/offers_model.dart b/lib/search_offers/model/offers_model.dart new file mode 100644 index 0000000..f7d7ef2 --- /dev/null +++ b/lib/search_offers/model/offers_model.dart @@ -0,0 +1,233 @@ +class OffersResponse { + final List offers; + final List categories; + + OffersResponse({ + required this.offers, + required this.categories, + }); + + factory OffersResponse.fromJson(Map json) { + return OffersResponse( + offers: (json['offers'] as List? ?? []) + .map((e) => Offer.fromJson(e)) + .toList(), + categories: (json['categories'] as List? ?? []) + .map((e) => Category.fromJson(e)) + .toList(), + ); + } + + Map toJson() { + return { + 'offers': offers.map((e) => e.toJson()).toList(), + 'categories': categories.map((e) => e.toJson()).toList(), + }; + } +} + +/* ----------------------- OFFER ----------------------- */ + +class Offer { + final int id; + final String title; + final String description; + final String offerCode; + final String partnerName; + final String passType; + final bool applyToPasses; + final String offerStatus; + final DateTime startDateTime; + final DateTime endDateTime; + final String? websiteBannerImage; + final String? mobileBannerImage; + final String redemptionLink; + final City? city; + final CardInfo? card; + final CardType cardType; + final Category category; + final DateTime createdAt; + final DateTime updatedAt; + + Offer({ + required this.id, + required this.title, + required this.description, + required this.offerCode, + required this.partnerName, + required this.passType, + required this.applyToPasses, + required this.offerStatus, + required this.startDateTime, + required this.endDateTime, + this.websiteBannerImage, + this.mobileBannerImage, + required this.redemptionLink, + this.city, + this.card, + required this.cardType, + required this.category, + required this.createdAt, + required this.updatedAt, + }); + + factory Offer.fromJson(Map json) { + return Offer( + id: json['id'], + title: json['title'] ?? '', + description: json['description'] ?? '', + offerCode: json['offerCode'] ?? '', + partnerName: json['partnerName'] ?? '', + passType: json['passType'] ?? '', + applyToPasses: json['applyToPasses'] ?? false, + offerStatus: json['offerStatus'] ?? '', + startDateTime: DateTime.parse(json['startDateTime']), + endDateTime: DateTime.parse(json['endDateTime']), + websiteBannerImage: json['websiteBannerImage'], + mobileBannerImage: json['mobileBannerImage'], + redemptionLink: json['redemptionLink'] ?? '', + city: json['city'] != null ? City.fromJson(json['city']) : null, + card: json['card'] != null ? CardInfo.fromJson(json['card']) : null, + cardType: CardType.fromJson(json['cardType']), + category: Category.fromJson(json['category']), + createdAt: DateTime.parse(json['createdAt']), + updatedAt: DateTime.parse(json['updatedAt']), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'offerCode': offerCode, + 'partnerName': partnerName, + 'passType': passType, + 'applyToPasses': applyToPasses, + 'offerStatus': offerStatus, + 'startDateTime': startDateTime.toIso8601String(), + 'endDateTime': endDateTime.toIso8601String(), + 'websiteBannerImage': websiteBannerImage, + 'mobileBannerImage': mobileBannerImage, + 'redemptionLink': redemptionLink, + 'city': city?.toJson(), + 'card': card?.toJson(), + 'cardType': cardType.toJson(), + 'category': category.toJson(), + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + } +} + +/* ----------------------- CITY ----------------------- */ + +class City { + final int id; + final String name; + + City({ + required this.id, + required this.name, + }); + + factory City.fromJson(Map json) { + return City( + id: json['id'], + name: json['name'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + }; + } +} + +/* ----------------------- CARD ----------------------- */ + +class CardInfo { + final int id; + final String title; + final int adultPrice; + final int childPrice; + + CardInfo({ + required this.id, + required this.title, + required this.adultPrice, + required this.childPrice, + }); + + factory CardInfo.fromJson(Map json) { + return CardInfo( + id: json['id'], + title: json['title'] ?? '', + adultPrice: json['adultPrice'] ?? 0, + childPrice: json['childPrice'] ?? 0, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'adultPrice': adultPrice, + 'childPrice': childPrice, + }; + } +} + +/* ----------------------- CARD TYPE ----------------------- */ + +class CardType { + final int id; + final String displayName; + + CardType({ + required this.id, + required this.displayName, + }); + + factory CardType.fromJson(Map json) { + return CardType( + id: json['id'], + displayName: json['displayName'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'displayName': displayName, + }; + } +} + +/* ----------------------- CATEGORY ----------------------- */ + +class Category { + final int id; + final String categoryName; + + Category({ + required this.id, + required this.categoryName, + }); + + factory Category.fromJson(Map json) { + return Category( + id: json['id'], + categoryName: json['categoryName'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'categoryName': categoryName, + }; + } +} diff --git a/lib/search_offers/repository/offers_repository.dart b/lib/search_offers/repository/offers_repository.dart new file mode 100644 index 0000000..6bc7ebe --- /dev/null +++ b/lib/search_offers/repository/offers_repository.dart @@ -0,0 +1,22 @@ +import '../model/offers_model.dart'; +import '../../networkApiServices/network_api_services.dart'; +import '../../networkApiServices/api_urls.dart'; + +class OffersRepository { + final NetworkApiService _apiService = NetworkApiService(); + + /// Fetch offers (optionally by categoryXid) + Future fetchOffers({ + int? categoryXid, + }) async { + final String url = categoryXid != null + ? '${ApiUrls.offers}?categoryXid=$categoryXid' + : ApiUrls.offers; + + final response = await _apiService.getApi( + url: url, + ); + + return OffersResponse.fromJson(response.data); + } +} diff --git a/lib/search_offers/view/search_offers_with_listing.dart b/lib/search_offers/view/search_offers_with_listing.dart index da217ed..059201b 100644 --- a/lib/search_offers/view/search_offers_with_listing.dart +++ b/lib/search_offers/view/search_offers_with_listing.dart @@ -2,22 +2,31 @@ import 'package:citycards_customer/common_packages/app_bar.dart'; import 'package:citycards_customer/common_packages/custom_search_field.dart'; import 'package:citycards_customer/common_packages/custom_text.dart'; import 'package:citycards_customer/core/route_constants.dart'; -import 'package:citycards_customer/search_offers/bloc/search_offers_listing_bloc.dart'; +import 'package:citycards_customer/search_offers/bloc/offers_bloc.dart'; +import 'package:citycards_customer/search_offers/bloc/offers_event.dart'; +import 'package:citycards_customer/search_offers/bloc/offers_state.dart'; +import 'package:citycards_customer/search_offers/repository/offers_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../common_packages/common_app_texts.dart'; +import '../../networkApiServices/api_urls.dart'; -class SearchOffersWithListing extends StatelessWidget { - SearchOffersWithListing({super.key}); +class OffersScreen extends StatefulWidget { + const OffersScreen({super.key}); - final List category = ["Beach", "Hike", "Popular", "Best in Summer"]; + @override + State createState() => _OffersScreenState(); +} + +class _OffersScreenState extends State { + int? selectedCategoryId; @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => OffersBloc()..add(LoadOffers()), + create: (_) => OffersBloc(OffersRepository())..add(LoadOffers()), child: Scaffold( backgroundColor: Colors.white, body: SafeArea( @@ -25,21 +34,28 @@ class SearchOffersWithListing extends StatelessWidget { padding: const EdgeInsets.all(12.0), child: Column( children: [ - CommonAppBar(isWhiteLogo: false, isProfilePage: false,showCart: false, showDivider: true,), + CommonAppBar( + isWhiteLogo: false, + isProfilePage: false, + showCart: false, + showDivider: true, + ), Row( children: [ GestureDetector( - onTap: (){ - Navigator.pop(context); - }, - child: Icon(Icons.arrow_back)), + onTap: () { + Navigator.pop(context); + }, + child: Icon(Icons.arrow_back), + ), SizedBox(width: 8.w), - CustomText(text: "Offers with ${CommonAppText.selectiveCard} Card", size: 12.sp), + CustomText( + text: "Offers with ${CommonAppText.selectiveCard} Card", + size: 12.sp, + ), ], ), - SizedBox(height: 33.h), - Builder( builder: (context) => CommonSearchField( hint: "Search offers", @@ -50,108 +66,274 @@ class SearchOffersWithListing extends StatelessWidget { }, ), ), - SizedBox(height: 20.h), - SingleChildScrollView( - scrollDirection: Axis.horizontal, + /// Dynamic Categories + BlocBuilder( + builder: (context, state) { + if (state is OffersLoaded) { + final categories = state.categories; - child: Row( - children: [ - ...List.generate(category.length, (index) { - return Padding( - padding: EdgeInsets.only(right: 8.0.w), - child: Container( - padding: EdgeInsets.symmetric( - vertical: 6.h, - horizontal: 12.w, - ), - decoration: BoxDecoration( - color: Color(0xFFFEE7E7), - borderRadius: BorderRadius.circular(100.sp), - border: Border.all(color: Color(0xFFFDCDCE)), - ), - child: Center( - child: CustomText(text: category[index]), - ), - ), - ); - }), - ], - ), + if (categories.isEmpty) { + return SizedBox.shrink(); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ...List.generate(categories.length, (index) { + final category = categories[index]; + final isSelected = + selectedCategoryId == category.id; + + return Padding( + padding: EdgeInsets.only(right: 8.0.w), + child: GestureDetector( + onTap: () { + setState(() { + if (selectedCategoryId == category.id) { + // Deselect if already selected + selectedCategoryId = null; + context + .read() + .add(LoadOffers()); + } else { + // Select new category + selectedCategoryId = category.id; + context.read().add( + LoadOffers( + categoryXid: category.id), + ); + } + }); + }, + child: Container( + padding: EdgeInsets.symmetric( + vertical: 8.h, + horizontal: 12.w, + ), + decoration: BoxDecoration( + color: isSelected + ? Color(0xFFF95F62) + : Color(0xFFFEE7E7), + borderRadius: + BorderRadius.circular(100.sp), + border: Border.all( + color: isSelected + ? Color(0xFFF95F62) + : Color(0xFFFDCDCE), + ), + ), + child: Center( + child: CustomText( + text: category.categoryName, + color: isSelected + ? Colors.white + : Color(0xFFF95F62), + ), + ), + ), + ), + ); + }), + ], + ), + ); + } + + return SizedBox.shrink(); + }, ), - SizedBox(height: 20.h), /// Offer list Expanded( child: BlocBuilder( builder: (context, state) { - final offers = state.offers; - - if (offers.isEmpty) { + if (state is OffersLoading) { return const Center( - child: Text( - "No offers found", - style: TextStyle(color: Colors.grey, fontSize: 14), + child: CircularProgressIndicator( + color: Color(0xFFF95F62), ), ); } - return GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 16.w, - mainAxisSpacing: 22.h, - childAspectRatio: 0.65, - ), - itemCount: offers.length, - itemBuilder: (context, index) { - final offer = offers[index]; - return InkWell( - onTap: (){ - Navigator.of(context).pushNamed(RouteConstants.offerPassDetail); - }, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 6.w, - vertical: 6.h, + if (state is OffersError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48.sp, + color: Colors.red, ), - decoration: BoxDecoration( - border: Border.all( - color: Color(0xFFF95F62).withOpacity(.24), + SizedBox(height: 16.h), + Text( + "Error: ${state.message}", + style: TextStyle( + color: Colors.red, + fontSize: 14.sp, ), - borderRadius: BorderRadius.circular(12.sp), + textAlign: TextAlign.center, ), - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8.sp), - child: Image.asset( - offer["image"] ?? "", - width: double.infinity, - height: 120.5.h, - fit: BoxFit.cover, - ), + ], + ), + ); + } + + if (state is OffersLoaded) { + final offers = state.offers; + + if (offers.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.local_offer_outlined, + size: 48.sp, + color: Colors.grey, + ), + SizedBox(height: 16.h), + Text( + "No offers found", + style: TextStyle( + color: Colors.grey, + fontSize: 14.sp, ), - SizedBox(height: 8.h), - CustomText( - text: offer["title"] ?? "", - size: 18.sp, - ), - SizedBox(height: 8.h), - CustomText( - text: offer["description"] ?? "", - color: Colors.black.withOpacity(.6), - size: 12.sp, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - ), + ), + ], ), ); - }, + } + + return GridView.builder( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16.w, + mainAxisSpacing: 22.h, + childAspectRatio: 0.65, + ), + itemCount: offers.length, + itemBuilder: (context, index) { + final offer = offers[index]; + return InkWell( + onTap: () { + Navigator.of(context).pushNamed( + RouteConstants.offerPassDetail, + arguments: offer.id, // ✅ pass offerId + ); + }, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 6.w, + vertical: 6.h, + ), + decoration: BoxDecoration( + border: Border.all( + color: + Color(0xFFF95F62).withOpacity(.24), + ), + borderRadius: BorderRadius.circular(12.sp), + ), + child: Column( + children: [ + ClipRRect( + borderRadius: + BorderRadius.circular(8.sp), + child: offer.mobileBannerImage != null && + offer.mobileBannerImage! + .isNotEmpty + ? Image.network( + '${ApiUrls.baseUrl}/${offer.mobileBannerImage}', + width: double.infinity, + height: 120.5.h, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) { + return Container( + width: double.infinity, + height: 120.5.h, + color: Color(0xFFFEE7E7), + child: Icon( + Icons.local_offer, + size: 40.sp, + color: Color(0xFFF95F62) + .withOpacity(.6), + ), + ); + }, + loadingBuilder: (context, child, + loadingProgress) { + if (loadingProgress == null) { + return child; + } + return Container( + width: double.infinity, + height: 120.5.h, + color: Color(0xFFFEE7E7), + child: Center( + child: + CircularProgressIndicator( + value: loadingProgress + .expectedTotalBytes != + null + ? loadingProgress + .cumulativeBytesLoaded / + loadingProgress + .expectedTotalBytes! + : null, + strokeWidth: 2, + color: + Color(0xFFF95F62), + ), + ), + ); + }, + ) + : Container( + width: double.infinity, + height: 120.5.h, + color: Color(0xFFFEE7E7), + child: Icon( + Icons.local_offer, + size: 40.sp, + color: Color(0xFFF95F62) + .withOpacity(.6), + ), + ), + ), + SizedBox(height: 8.h), + CustomText( + text: offer.title, + size: 18.sp, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 8.h), + CustomText( + text: offer.description, + color: Colors.black.withOpacity(.6), + size: 12.sp, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ); + } + + return const Center( + child: Text( + "No data available", + style: TextStyle(color: Colors.grey, fontSize: 14), + ), ); }, ), @@ -163,4 +345,4 @@ class SearchOffersWithListing extends StatelessWidget { ), ); } -} +} \ No newline at end of file