added offers , offer details and pass details api and more chnages

This commit is contained in:
mystery012728
2026-01-29 19:32:11 +05:30
parent 0434b16bde
commit fa4f78bceb
32 changed files with 3099 additions and 959 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 749 KiB

View File

@@ -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<BuyPassEvent, BuyPassState> {
final BuyPassRepository repository;
BuyPassBloc({required this.repository}) : super(BuyPassInitial()) {
/// Handle fetch buy pass data event
on<FetchBuyPassData>(_onFetchBuyPassData);
/// Handle change selected card event
on<ChangeSelectedCard>(_onChangeSelectedCard);
/// Handle update adult count event
on<UpdateAdultCount>(_onUpdateAdultCount);
/// Handle update child count event
on<UpdateChildCount>(_onUpdateChildCount);
/// Handle update validity duration event
on<UpdateValidityDuration>(_onUpdateValidityDuration); // ✅ Added
}
/// Fetch buy pass data from repository
Future<void> _onFetchBuyPassData(
FetchBuyPassData event,
Emitter<BuyPassState> 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<BuyPassState> 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<BuyPassState> 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<BuyPassState> 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<BuyPassState> 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));
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<Offer> offers;
final List<CardPass> cards;
final List<Attraction> attractions;
BuyPassModel({
required this.city,
required this.offers,
required this.cards,
required this.attractions,
});
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
return BuyPassModel(
city: City.fromJson(json['city']),
offers: List<Offer>.from(
json['offers'].map((x) => Offer.fromJson(x)),
),
cards: List<CardPass>.from(
json['cards'].map((x) => CardPass.fromJson(x)),
),
attractions: List<Attraction>.from(
json['attractions'].map((x) => Attraction.fromJson(x)),
),
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return HeroBanner(
title: json['title'],
image: json['image'],
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<Offer> 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<String, dynamic> 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<Offer>.from(
json['offers'].map((x) => Offer.fromJson(x)),
),
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return CardType(
id: json['id'],
name: json['name'],
displayName: json['displayName'],
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return Attraction(
id: json['id'],
title: json['title'],
slug: json['slug'],
thumbnail: json['thumbnail'],
startingFrom: json['startingFrom'],
);
}
Map<String, dynamic> toJson() => {
"id": id,
"title": title,
"slug": slug,
"thumbnail": thumbnail,
"startingFrom": startingFrom,
};
}

View File

@@ -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<BuyPassModel> fetchBuyPass() async {
final int cityId = await LocalPreference.getSelectedCityId();
final response = await _apiService.getApi(
url: '${ApiUrls.buyAPass}/$cityId',
);
return BuyPassModel.fromJson(response.data);
}
}

View File

@@ -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<BuyPassBloc, BuyPassState>(
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<BuyPassBloc>().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<BuyPassBloc>().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<BuyPassBloc>().add(
UpdateAdultCount(count),
);
},
onChildChanged: (count) {
context.read<BuyPassBloc>().add(
UpdateChildCount(count),
);
},
onValidityChanged: (duration) {
context.read<BuyPassBloc>().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();
},
),
),
);
}
}
}

View File

@@ -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,
),
),
),
),
],
),
);
}
}
}

View File

@@ -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<PaymentCard> createState() => _PaymentCardState();
}
class _PaymentCardState extends State<PaymentCard> {
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<int> 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<int>(
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<int>(
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<PaymentCard> {
);
}
/// Circle button for increment/decrement
Widget _circleButton(IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
@@ -173,9 +270,9 @@ class _PaymentCardState extends State<PaymentCard> {
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),
),
);
}
}
}

View File

@@ -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: (_) {

View File

@@ -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(),
);
},
);

View File

@@ -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,

View File

@@ -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

View File

@@ -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<OfferDetailsEvent, OfferDetailsState> {
final OffersDetailsRepository repository;
OfferDetailsBloc({required this.repository})
: super(OfferDetailsInitial()) {
on<FetchOfferDetailsEvent>(_onFetchOfferDetails);
}
Future<void> _onFetchOfferDetails(
FetchOfferDetailsEvent event,
Emitter<OfferDetailsState> emit,
) async {
emit(OfferDetailsLoading());
try {
final offerDetails = await repository.fetchOfferDetails(
offerId: event.offerId,
);
emit(OfferDetailsLoaded(offerDetails: offerDetails));
} catch (e) {
emit(
OfferDetailsError(
message: e.toString(),
),
);
}
}
}

View File

@@ -0,0 +1,18 @@
import 'package:equatable/equatable.dart';
abstract class OfferDetailsEvent extends Equatable {
const OfferDetailsEvent();
@override
List<Object?> get props => [];
}
/// Fetch offer details by offerId
class FetchOfferDetailsEvent extends OfferDetailsEvent {
final int offerId;
const FetchOfferDetailsEvent({required this.offerId});
@override
List<Object?> get props => [offerId];
}

View File

@@ -0,0 +1,35 @@
import 'package:equatable/equatable.dart';
import '../models/offer_details_model.dart';
abstract class OfferDetailsState extends Equatable {
const OfferDetailsState();
@override
List<Object?> 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<Object?> get props => [offerDetails];
}
/// Error state
class OfferDetailsError extends OfferDetailsState {
final String message;
const OfferDetailsError({required this.message});
@override
List<Object?> get props => [message];
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return Category(
id: json['id'] ?? 0,
categoryName: json['categoryName'] ?? 'N/A',
);
}
factory Category.empty() =>
Category(id: 0, categoryName: 'N/A');
}

View File

@@ -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<OfferDetailsBloc, OfferDetailsState>(
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();
},
),
),
);

View File

@@ -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<OfferDetailsModel> fetchOfferDetails({
required int offerId,
}) async {
final response = await _apiService.getApi(
url: '${ApiUrls.offersDetails}/$offerId',
);
return OfferDetailsModel.fromJson(response.data);
}
}

View File

@@ -21,53 +21,86 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
Emitter<ProfileState> 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<void> _onUpdateProfile(
UpdateProfileEvent event,
Emitter<ProfileState> 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<ProfileEvent, ProfileState> {
ResetProfileEvent event,
Emitter<ProfileState> emit,
) {
if (kDebugMode) {
print('🔄 [BLOC] ResetProfileEvent received');
}
emit(const ProfileInitial());
}
}
}

View File

@@ -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<String, dynamic> 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
};
}
}

View File

@@ -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 {

View File

@@ -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<ProfileModel> 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<ProfileModel> updateUserProfile({
required Map<String, dynamic> 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);
}
}
}

View File

@@ -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<EditProfilePage> {
final TextEditingController address2Controller = TextEditingController();
final _formKey = GlobalKey<FormState>();
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<EditProfilePage> {
}
Future<void> _fetchProfile() async {
if (kDebugMode) {
print('🔵 [EDIT PROFILE] Fetching profile...');
}
final userId = await LocalPreference.getUserId();
if (userId != null) {
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId));
@@ -43,18 +56,226 @@ class _EditProfilePageState extends State<EditProfilePage> {
}
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<void> _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<void> _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<ProfileBloc>().add(
UpdateProfileEvent(
userId: userId,
@@ -67,6 +288,7 @@ class _EditProfilePageState extends State<EditProfilePage> {
address2: address2Controller.text.trim().isEmpty
? null
: address2Controller.text.trim(),
profileImageFile: _selectedImageFile, // Pass File directly
),
);
}
@@ -90,6 +312,10 @@ class _EditProfilePageState extends State<EditProfilePage> {
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<EditProfilePage> {
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<EditProfilePage> {
}
},
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<EditProfilePage> {
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<EditProfilePage> {
),
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<EditProfilePage> {
),
),
// Last Name
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
@@ -195,7 +429,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
),
),
// Phone Number
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
@@ -214,7 +447,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
SizedBox(height: 20.h),
// Location Details
Align(
alignment: Alignment.centerLeft,
child: CustomText(
@@ -225,7 +457,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
),
SizedBox(height: 16.h),
// Address 1
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
child: CustomTextField(
@@ -236,7 +467,6 @@ class _EditProfilePageState extends State<EditProfilePage> {
),
),
// Address 2
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
child: CustomTextField(
@@ -257,18 +487,13 @@ class _EditProfilePageState extends State<EditProfilePage> {
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<EditProfilePage> {
),
),
// Loading overlay when saving
if (state is ProfileUpdating)
Container(
color: Colors.black.withOpacity(0.3),

View File

@@ -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<ProfilePage> {
// 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 = <String>[];
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<String> 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<ProfilePage> {
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<ProfilePage> {
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<ProfilePage> {
SizedBox(height: 30.h),
// Account Settings Section
/// ================= Account Settings =================
Align(
alignment: Alignment.centerLeft,
child: CustomText(
@@ -401,6 +441,7 @@ class _ProfilePageState extends State<ProfilePage> {
size: 18.sp,
),
),
SizedBox(height: 10.h),
_buildListTile(
@@ -412,7 +453,6 @@ class _ProfilePageState extends State<ProfilePage> {
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<ProfilePage> {
}
},
),
_buildListTile(
icon: "assets/icons/change_language.png",
title: 'Change language',
@@ -442,6 +483,7 @@ class _ProfilePageState extends State<ProfilePage> {
);
},
),
SizedBox(height: 24.h),
],
);

View File

@@ -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<OffersEvent, OffersState> {
final OffersRepository repository;
List<Offer> _allOffers = [];
OffersBloc(this.repository) : super(OffersInitial()) {
on<LoadOffers>(_onLoadOffers);
on<SearchOffers>(_onSearchOffers);
}
Future<void> _onLoadOffers(
LoadOffers event,
Emitter<OffersState> 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<OffersState> 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,
),
);
}
}
}

View File

@@ -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);
}

View File

@@ -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<Offer> offers;
final List<Category> categories;
OffersLoaded({
required this.offers,
required this.categories,
});
}
class OffersError extends OffersState {
final String message;
OffersError(this.message);
}

View File

@@ -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<Map<String, String>> offers;
const OffersState(this.offers);
}
// ----- Bloc -----
class OffersBloc extends Bloc<OffersEvent, OffersState> {
OffersBloc() : super(const OffersState([])) {
on<LoadOffers>(_onLoadOffers);
on<SearchOffers>(_onSearchOffers);
}
final List<Map<String, String>> _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<Map<String, String>> offers;
// const OffersState(this.offers);
// }
//
// // ----- Bloc -----
// class OffersBloc extends Bloc<OffersEvent, OffersState> {
// OffersBloc() : super(const OffersState([])) {
// on<LoadOffers>(_onLoadOffers);
// on<SearchOffers>(_onSearchOffers);
// }
//
// final List<Map<String, String>> _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));
// }
// }

View File

@@ -0,0 +1,233 @@
class OffersResponse {
final List<Offer> offers;
final List<Category> categories;
OffersResponse({
required this.offers,
required this.categories,
});
factory OffersResponse.fromJson(Map<String, dynamic> json) {
return OffersResponse(
offers: (json['offers'] as List<dynamic>? ?? [])
.map((e) => Offer.fromJson(e))
.toList(),
categories: (json['categories'] as List<dynamic>? ?? [])
.map((e) => Category.fromJson(e))
.toList(),
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return City(
id: json['id'],
name: json['name'] ?? '',
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return CardInfo(
id: json['id'],
title: json['title'] ?? '',
adultPrice: json['adultPrice'] ?? 0,
childPrice: json['childPrice'] ?? 0,
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return CardType(
id: json['id'],
displayName: json['displayName'] ?? '',
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return Category(
id: json['id'],
categoryName: json['categoryName'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'categoryName': categoryName,
};
}
}

View File

@@ -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<OffersResponse> 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);
}
}

View File

@@ -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<String> category = ["Beach", "Hike", "Popular", "Best in Summer"];
@override
State<OffersScreen> createState() => _OffersScreenState();
}
class _OffersScreenState extends State<OffersScreen> {
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<OffersBloc, OffersState>(
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<OffersBloc>()
.add(LoadOffers());
} else {
// Select new category
selectedCategoryId = category.id;
context.read<OffersBloc>().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<OffersBloc, OffersState>(
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 {
),
);
}
}
}