4 Commits

Author SHA1 Message Date
mystery012728
48fd7037ea snack bar bug solved 2026-02-13 18:34:00 +05:30
mystery012728
40f0ed3a52 pull taken from shree branch and conflict fixes 2026-02-13 17:13:22 +05:30
mystery012728
b08e2699e9 added my passes and more chnages 2026-02-13 15:27:14 +05:30
mystery012728
5d08e07de3 added pass details screen new and updated create account page and more changes... 2026-02-10 19:05:42 +05:30
103 changed files with 6967 additions and 1370 deletions

BIN
assets/icons/calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 B

BIN
assets/icons/person.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/icons/time.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -9,6 +9,9 @@ import 'stripe_payment_state.dart';
class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
final StripeService _stripeService;
// 🔒 Flag to prevent re-initialization after success
bool _paymentCompleted = false;
StripePaymentBloc({
StripeService? stripeService,
}) : _stripeService = stripeService ?? StripeService(),
@@ -24,6 +27,12 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
InitiatePayment event,
Emitter<StripePaymentState> emit,
) async {
// 🛑 Prevent re-initialization if payment already completed
if (_paymentCompleted) {
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
return;
}
try {
emit(const StripePaymentLoading(
message: 'Creating payment intent...',
@@ -61,7 +70,8 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
// 3⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS
// ✅ SUCCESS - Mark as completed
_paymentCompleted = true;
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
_handleStripeException(e, emit);
@@ -78,6 +88,12 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
InitiatePaymentWithClientSecret event,
Emitter<StripePaymentState> emit,
) async {
// 🛑 Prevent re-initialization if payment already completed
if (_paymentCompleted) {
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
return;
}
try {
emit(const StripePaymentLoading(
message: 'Initializing payment...',
@@ -101,7 +117,8 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
// 2⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS
// ✅ SUCCESS - Mark as completed
_paymentCompleted = true;
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
_handleStripeException(e, emit);
@@ -118,16 +135,22 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
CancelPaymentEvent event,
Emitter<StripePaymentState> emit,
) {
// Only emit cancelled if not already completed
if (!_paymentCompleted) {
emit(const StripePaymentCancelled(
message: 'Payment cancelled by user',
));
}
}
/// Handle payment retry
Future<void> _onRetryPayment(
RetryPaymentEvent event,
Emitter<StripePaymentState> emit,
) async {
// 🔄 Reset completion flag for retry
_paymentCompleted = false;
// Reset state first
emit(const StripePaymentInitial());
@@ -142,6 +165,8 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
ResetPaymentState event,
Emitter<StripePaymentState> emit,
) {
// 🔄 Reset completion flag
_paymentCompleted = false;
emit(const StripePaymentInitial());
}
@@ -199,4 +224,11 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
return !nonRetryableErrors.contains(errorCode);
}
@override
Future<void> close() {
// Reset flag on bloc disposal
_paymentCompleted = false;
return super.close();
}
}

View File

@@ -199,8 +199,18 @@ class StripePaymentScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocConsumer<StripePaymentBloc, StripePaymentState>(
// 🔒 CRITICAL: Only listen when state actually changes to prevent duplicate triggers
listenWhen: (previous, current) {
// Don't re-trigger if both states are the same success state
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
debugPrint('⚠️ Preventing duplicate success listener');
return false;
}
return true;
},
listener: (context, state) {
if (state is StripePaymentSuccess) {
debugPrint('✅ Payment Success - Calling callback');
// ✅ Call the callback first
onPaymentSuccess?.call();
// ✅ Then auto-close and return true after 1.5 seconds
@@ -210,6 +220,7 @@ class StripePaymentScreen extends StatelessWidget {
}
});
} else if (state is StripePaymentFailure) {
debugPrint('❌ Payment Failure - ${state.error}');
onPaymentFailure?.call(state.error);
// Auto-close after 2 seconds on failure
Future.delayed(const Duration(seconds: 2), () {
@@ -218,10 +229,18 @@ class StripePaymentScreen extends StatelessWidget {
}
});
} else if (state is StripePaymentCancelled) {
debugPrint('🚫 Payment Cancelled');
onPaymentCancelled?.call();
Navigator.of(context).pop(false);
}
},
buildWhen: (previous, current) {
// 🔒 Prevent unnecessary rebuilds on duplicate success states
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
return false;
}
return true;
},
builder: (context, state) {
return Container(
height: heightRatio == 1.0
@@ -394,7 +413,7 @@ class StripePaymentScreen extends StatelessWidget {
onPressed: () {
// Retry payment
context.read<StripePaymentBloc>().add(
InitiatePaymentWithClientSecret(
RetryPaymentEvent(
clientSecret: clientSecret,
),
);

View File

@@ -81,12 +81,12 @@ class _AddDetailsViewState extends State<AddDetailsView> {
// Handle API submission success
if (state is PurchaseDetailsSubmitted) {
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Gift details submitted successfully!'),
backgroundColor: Color(0xffF95F62),
),
);
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text('Gift details submitted successfully!'),
// backgroundColor: Color(0xffF95F62),
// ),
// );
// Navigate back
Navigator.of(context).pop('success');
@@ -231,7 +231,7 @@ class _AddDetailsViewState extends State<AddDetailsView> {
selectedCountry = value;
});
},
items: ["India", "USA", "UK", "Canada"]
items: ["Australia"]
.map((value) {
return DropdownMenuItem<String>(
value: value,

View File

@@ -26,15 +26,18 @@ class ShareBottomSheet extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// drag handle
Container(
height: 4.h,
width: 47.w,
margin: EdgeInsets.only(bottom: 16),
margin: EdgeInsets.only(bottom: 16.h),
decoration: BoxDecoration(
color: Color(0xFF222222),
color: const Color(0xFF222222),
borderRadius: BorderRadius.circular(8),
),
),
// link field
TextField(
readOnly: true,
decoration: InputDecoration(
@@ -51,7 +54,10 @@ class ShareBottomSheet extends StatelessWidget {
),
),
),
SizedBox(height: 20.h),
// grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -67,7 +73,16 @@ class ShareBottomSheet extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(item['icon']!, width: 55.w),
// FIXED SIZE ICON CONTAINER
Container(
width: 55.w,
height: 55.w,
alignment: Alignment.center,
child: Image.asset(
item['icon']!,
fit: BoxFit.contain,
),
),
SizedBox(height: 8.h),
Text(
item['title']!,
@@ -78,7 +93,10 @@ class ShareBottomSheet extends StatelessWidget {
);
},
),
const SizedBox(height: 20),
// page indicator
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
@@ -88,13 +106,16 @@ class ShareBottomSheet extends StatelessWidget {
width: 8.w,
height: 8.h,
decoration: BoxDecoration(
color: index == 0 ? Color(0xFF676363) : Colors.white,
border: Border.all(color: Color(0xFF676363)),
color: index == 0
? const Color(0xFF676363)
: Colors.white,
border: Border.all(color: const Color(0xFF676363)),
shape: BoxShape.circle,
),
),
),
),
SizedBox(height: 10.h),
],
),

View File

@@ -37,9 +37,9 @@ class Attraction {
final String title;
final String description;
final String urlSlug;
final int cityXid;
final int cardTypeXid;
final int partnerXid;
final num cityXid;
final num cardTypeXid;
final num partnerXid;
final String productCode;
final bool isBookingRequired;
@@ -47,14 +47,14 @@ class Attraction {
final String bookingEmail;
final String bookingPhoneNumber;
final double latitudeCoordinate;
final double longitudeCoordinate;
final num latitudeCoordinate;
final num longitudeCoordinate;
final String address;
final double? ticketPriceAdult;
final double? ticketPriceChild;
final int durations;
final int groupSize;
final num? ticketPriceAdult;
final num? ticketPriceChild;
final num durations;
final num groupSize;
final String ageRange;
final String seoTitle;
@@ -115,13 +115,11 @@ class Attraction {
isPartnerAccess: json['isPartnerAccess'] ?? false,
bookingEmail: json['bookingEmail'] ?? '',
bookingPhoneNumber: json['bookingPhonenumber'] ?? '',
latitudeCoordinate:
(json['latitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
longitudeCoordinate:
(json['longitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
latitudeCoordinate: (json['latitudeCoordinate'] as num?) ?? 0,
longitudeCoordinate: (json['longitudeCoordinate'] as num?) ?? 0,
address: json['address'] ?? '',
ticketPriceAdult: (json['ticketPriceAdult'] as num?)?.toDouble(),
ticketPriceChild: (json['ticketPriceChild'] as num?)?.toDouble(),
ticketPriceAdult: json['ticketPriceAdult'] as num?,
ticketPriceChild: json['ticketPriceChild'] as num?,
durations: json['durations'] ?? 0,
groupSize: json['groupSize'] ?? 0,
ageRange: json['ageRange'] ?? '',
@@ -197,9 +195,9 @@ class Attraction {
class CardModel {
final int id;
final String title;
final int cardTypeXid;
final int adultPrice;
final int childPrice;
final num cardTypeXid;
final num adultPrice;
final num childPrice;
final String cardStatus;
CardModel({
@@ -234,7 +232,6 @@ class CardModel {
}
}
/* -------------------- GALLERY -------------------- */
class Gallery {
@@ -275,7 +272,6 @@ class Gallery {
bool get hasImage => filePathUrl.isNotEmpty;
}
/* -------------------- CATEGORY -------------------- */
class Category {
@@ -301,4 +297,3 @@ class Category {
};
}
}

View File

@@ -24,7 +24,7 @@ class AttractionCard extends StatelessWidget {
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.attractionDetails,
arguments: attraction,
arguments: attraction.id,
);
},
child: Container(
@@ -61,6 +61,8 @@ class AttractionCard extends StatelessWidget {
children: [
Text(
attraction.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
@@ -71,6 +73,8 @@ class AttractionCard extends StatelessWidget {
Text(
attraction.address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w400,
@@ -104,10 +108,8 @@ class AttractionCard extends StatelessWidget {
),
SizedBox(height: 6.h),
/// TAGS (CARD TITLES)
attraction.isBookingRequired == false
? Wrap(
Wrap(
spacing: 6.w,
runSpacing: 6.h,
children: tags
@@ -145,27 +147,6 @@ class AttractionCard extends StatelessWidget {
)
.toList(),
)
: Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: const Color(0xffC1D2F8),
border: Border.all(
color: const Color(0xff2563EB),
),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
"Booking Required",
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: const Color(0xff1A1A1A),
fontWeight: FontWeight.w400,
),
),
),
],
),
),

View File

@@ -8,10 +8,10 @@ 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;
City city;
List<Offer> offers;
List<CardPass> cards;
List<Attraction> attractions;
BuyPassModel({
required this.city,
@@ -20,41 +20,49 @@ class BuyPassModel {
required this.attractions,
});
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
factory BuyPassModel.fromJson(Map<String, dynamic>? json) {
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)),
),
offers: json['offers'] == null
? []
: List<Map<String, dynamic>>.from(json['offers'])
.map((e) => Offer.fromJson(e))
.toList(),
cards: json['cards'] == null
? []
: List<Map<String, dynamic>>.from(json['cards'])
.map((e) => CardPass.fromJson(e))
.toList(),
attractions: json['attractions'] == null
? []
: List<Map<String, dynamic>>.from(json['attractions'])
.map((e) => Attraction.fromJson(e))
.toList(),
);
}
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(),
"offers": offers.map((e) => e.toJson()).toList(),
"cards": cards.map((e) => e.toJson()).toList(),
"attractions": attractions.map((e) => e.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 num individualTicketAmount; // Changed from int to num
final num cityCardTicketAmount; // Changed from int to num
final HeroBanner heroBanner;
int id;
String name;
String slug;
String tagLine;
String description;
String bestTimeToVisit;
String priceRange;
num individualTicketAmount;
num cityCardTicketAmount;
HeroBanner heroBanner;
City({
required this.id,
@@ -69,17 +77,19 @@ class City {
required this.heroBanner,
});
factory City.fromJson(Map<String, dynamic> json) {
factory City.fromJson(Map<String, dynamic>? json) {
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'],
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? "",
slug: json['slug']?.toString() ?? "",
tagLine: json['tagLine']?.toString() ?? "",
description: json['description']?.toString() ?? "",
bestTimeToVisit: json['bestTimeToVisit']?.toString() ?? "",
priceRange: json['priceRange']?.toString() ?? "",
individualTicketAmount: json['individualTicketAmount'] ?? 0,
cityCardTicketAmount: json['cityCardTicketAmount'] ?? 0,
heroBanner: HeroBanner.fromJson(json['heroBanner']),
);
}
@@ -100,18 +110,20 @@ class City {
/// ---------- HERO BANNER ----------
class HeroBanner {
final String title;
final String image;
String title;
String image;
HeroBanner({
required this.title,
required this.image,
});
factory HeroBanner.fromJson(Map<String, dynamic> json) {
factory HeroBanner.fromJson(Map<String, dynamic>? json) {
json ??= {};
return HeroBanner(
title: json['title'],
image: json['image'],
title: json['title']?.toString() ?? "",
image: json['image']?.toString() ?? "",
);
}
@@ -123,25 +135,25 @@ class HeroBanner {
/// ---------- 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;
int id;
String title;
String offerCode;
String description;
String redemptionLink;
String websiteBannerImage;
String mobileBannerImage;
String passType;
DateTime startDateTime;
DateTime endDateTime;
String offerStatus;
bool applyToPasses;
Offer({
required this.id,
required this.title,
required this.offerCode,
this.description,
this.redemptionLink,
required this.description,
required this.redemptionLink,
required this.websiteBannerImage,
required this.mobileBannerImage,
required this.passType,
@@ -151,20 +163,24 @@ class Offer {
required this.applyToPasses,
});
factory Offer.fromJson(Map<String, dynamic> json) {
factory Offer.fromJson(Map<String, dynamic>? json) {
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'],
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title']?.toString() ?? "",
offerCode: json['offerCode']?.toString() ?? "",
description: json['description']?.toString() ?? "",
redemptionLink: json['redemptionLink']?.toString() ?? "",
websiteBannerImage: json['websiteBannerImage']?.toString() ?? "",
mobileBannerImage: json['mobileBannerImage']?.toString() ?? "",
passType: json['passType']?.toString() ?? "",
startDateTime: DateTime.tryParse(json['startDateTime'] ?? "") ??
DateTime.fromMillisecondsSinceEpoch(0),
endDateTime: DateTime.tryParse(json['endDateTime'] ?? "") ??
DateTime.fromMillisecondsSinceEpoch(0),
offerStatus: json['offerStatus']?.toString() ?? "",
applyToPasses: json['applyToPasses'] ?? false,
);
}
@@ -186,16 +202,16 @@ class Offer {
/// ---------- CARD PASS ----------
class CardPass {
final int id;
final String title;
final String description;
final int validityDuration;
final num adultPrice; // Changed from int to num
final num childPrice; // Changed from int to num
final int minNumber; // ✅ NEW
final int maxNumber; // ✅ NEW
final CardType cardType;
final List<Offer> offers;
int id;
String title;
String description;
int validityDuration;
num adultPrice;
num childPrice;
int minNumber;
int maxNumber;
CardType cardType;
List<Offer> offers;
CardPass({
required this.id,
@@ -210,20 +226,24 @@ class CardPass {
required this.offers,
});
factory CardPass.fromJson(Map<String, dynamic> json) {
factory CardPass.fromJson(Map<String, dynamic>? json) {
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'], // ✅
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title']?.toString() ?? "",
description: json['description']?.toString() ?? "",
validityDuration: (json['validityDuration'] as num?)?.toInt() ?? 0,
adultPrice: json['adultPrice'] ?? 0,
childPrice: json['childPrice'] ?? 0,
minNumber: (json['minNumber'] as num?)?.toInt() ?? 0,
maxNumber: (json['maxNumber'] as num?)?.toInt() ?? 0,
cardType: CardType.fromJson(json['cardType']),
offers: List<Offer>.from(
json['offers'].map((x) => Offer.fromJson(x)),
),
offers: json['offers'] == null
? []
: List<Map<String, dynamic>>.from(json['offers'])
.map((e) => Offer.fromJson(e))
.toList(),
);
}
@@ -237,15 +257,15 @@ class CardPass {
"minNumber": minNumber,
"maxNumber": maxNumber,
"cardType": cardType.toJson(),
"offers": offers.map((x) => x.toJson()).toList(),
"offers": offers.map((e) => e.toJson()).toList(),
};
}
/// ---------- CARD TYPE ----------
class CardType {
final int id;
final String name;
final String displayName;
int id;
String name;
String displayName;
CardType({
required this.id,
@@ -253,11 +273,13 @@ class CardType {
required this.displayName,
});
factory CardType.fromJson(Map<String, dynamic> json) {
factory CardType.fromJson(Map<String, dynamic>? json) {
json ??= {};
return CardType(
id: json['id'],
name: json['name'],
displayName: json['displayName'],
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? "",
displayName: json['displayName']?.toString() ?? "",
);
}
@@ -270,27 +292,29 @@ class CardType {
/// ---------- ATTRACTION ----------
class Attraction {
final int id;
final String title;
final String slug;
final String thumbnail;
final num? startingFrom; // Changed from int? to num?
int id;
String title;
String slug;
String thumbnail;
num startingFrom;
Attraction({
required this.id,
required this.title,
required this.slug,
required this.thumbnail,
this.startingFrom,
required this.startingFrom,
});
factory Attraction.fromJson(Map<String, dynamic> json) {
factory Attraction.fromJson(Map<String, dynamic>? json) {
json ??= {};
return Attraction(
id: json['id'],
title: json['title'],
slug: json['slug'],
thumbnail: json['thumbnail'],
startingFrom: json['startingFrom'],
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title']?.toString() ?? "",
slug: json['slug']?.toString() ?? "",
thumbnail: json['thumbnail']?.toString() ?? "",
startingFrom: json['startingFrom'] ?? 0,
);
}

View File

@@ -401,10 +401,10 @@ class BuyPassContent extends StatelessWidget {
),
child: GestureDetector(
onTap: () {
// Navigator.of(context).pushNamed(
// RouteConstants.attractionDetails,
// arguments: attraction,
// );
Navigator.of(context).pushNamed(
RouteConstants.attractionDetails,
arguments: attraction.id,
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(8.r),

View File

@@ -95,7 +95,7 @@ class PaymentCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20.r),
),
child: CustomText(
text: "$cardDisplayName Card",
text: cardDisplayName,
size: 12.sp,
color: Colors.white,
weight: FontWeight.w500,

View File

@@ -8,18 +8,122 @@ class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
final MyPassCartRepository repository;
MyPassCartBloc({required this.repository}) : super(const MyPassCartInitial()) {
on<CheckLoginAndFetchEvent>(_onCheckLoginAndFetch);
on<FetchPassCartEvent>(_onFetchPassCart);
on<ClearPassCartEvent>(_onClearPassCart);
}
/// Handle fetching pass cart data
/// Handle checking login status and fetching cart data accordingly
Future<void> _onCheckLoginAndFetch(
CheckLoginAndFetchEvent event,
Emitter<MyPassCartState> emit,
) async {
try {
if (kDebugMode) {
print('🔍 [BLOC] Checking login status and fetching cart...');
}
emit(const MyPassCartLoading());
// Check if user is logged in
final isLoggedIn = await repository.isUserLoggedIn();
if (kDebugMode) {
print('🔐 [BLOC] User logged in: $isLoggedIn');
}
if (isLoggedIn) {
// User is logged in - fetch from API
if (kDebugMode) {
print('🌐 [BLOC] Fetching cart data from API...');
}
try {
final apiCartData = await repository.fetchMyPassesCart();
// Check if API data is empty
if (apiCartData.cartItems.isEmpty) {
if (kDebugMode) {
print('⚠️ [BLOC] API returned empty cart, checking local data...');
}
// Try to fetch from local if API is empty
final localCartData = await repository.fetchPassesCartByLocal();
if (localCartData != null) {
if (kDebugMode) {
print('✅ [BLOC] Using local cart data as fallback');
}
emit(MyPassCartLoaded(cartData: localCartData));
} else {
if (kDebugMode) {
print(' [BLOC] No local data available, cart is empty');
}
emit(const MyPassCartEmpty());
}
} else {
// API has cart items
if (kDebugMode) {
print('✅ [BLOC] API cart data loaded successfully with ${apiCartData.cartItems.length} items');
}
emit(MyPassCartApiLoaded(apiCartData: apiCartData));
}
} catch (apiError) {
if (kDebugMode) {
print('❌ [BLOC] API error: $apiError, trying local data...');
}
// API failed, try local data as fallback
final localCartData = await repository.fetchPassesCartByLocal();
if (localCartData != null) {
if (kDebugMode) {
print('✅ [BLOC] Using local cart data after API failure');
}
emit(MyPassCartLoaded(cartData: localCartData));
} else {
if (kDebugMode) {
print('❌ [BLOC] No local data available after API failure');
}
emit(MyPassCartError(message: 'Failed to load cart data: ${apiError.toString()}'));
}
}
} else {
// User is not logged in - fetch from local only
if (kDebugMode) {
print('📱 [BLOC] User not logged in, fetching from local storage...');
}
final localCartData = await repository.fetchPassesCartByLocal();
if (localCartData != null) {
if (kDebugMode) {
print('✅ [BLOC] Local cart data loaded successfully');
}
emit(MyPassCartLoaded(cartData: localCartData));
} else {
if (kDebugMode) {
print(' [BLOC] No local cart data available');
}
emit(const MyPassCartEmpty());
}
}
} catch (e) {
if (kDebugMode) {
print('❌ [BLOC] Error in CheckLoginAndFetch: $e');
}
emit(MyPassCartError(message: e.toString()));
}
}
/// Handle fetching pass cart data from local storage
Future<void> _onFetchPassCart(
FetchPassCartEvent event,
Emitter<MyPassCartState> emit,
) async {
try {
if (kDebugMode) {
print('🔄 [BLOC] Fetching pass cart...');
print('📄 [BLOC] Fetching pass cart from local...');
}
emit(const MyPassCartLoading());
@@ -52,7 +156,7 @@ class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
) async {
try {
if (kDebugMode) {
print('🔄 [BLOC] Clearing pass cart...');
print('📄 [BLOC] Clearing pass cart...');
}
// You can add clearPassCart method to repository if needed

View File

@@ -7,6 +7,14 @@ abstract class MyPassCartEvent extends Equatable {
List<Object?> get props => [];
}
/// Event to check login status and fetch pass cart data accordingly
/// - If logged in: fetch from API
/// - If not logged in: fetch from local
/// - If API returns empty and local data exists: use local data
class CheckLoginAndFetchEvent extends MyPassCartEvent {
const CheckLoginAndFetchEvent();
}
/// Event to fetch pass cart data from local database
class FetchPassCartEvent extends MyPassCartEvent {
const FetchPassCartEvent();

View File

@@ -1,5 +1,7 @@
import 'package:equatable/equatable.dart';
import '../../model/my_passes_cart_mode.dart';
abstract class MyPassCartState extends Equatable {
const MyPassCartState();
@@ -17,7 +19,7 @@ class MyPassCartLoading extends MyPassCartState {
const MyPassCartLoading();
}
/// Loaded state with cart data
/// Loaded state with cart data from local storage
class MyPassCartLoaded extends MyPassCartState {
final Map<String, dynamic> cartData;
@@ -27,6 +29,16 @@ class MyPassCartLoaded extends MyPassCartState {
List<Object?> get props => [cartData];
}
/// Loaded state with cart data from API
class MyPassCartApiLoaded extends MyPassCartState {
final MyPassesCartModel apiCartData;
const MyPassCartApiLoaded({required this.apiCartData});
@override
List<Object?> get props => [apiCartData];
}
/// Empty state when no cart data exists
class MyPassCartEmpty extends MyPassCartState {
const MyPassCartEmpty();

View File

@@ -1,40 +1,40 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../model/pass_model.dart';
abstract class PassEvent {}
class LoadPasses extends PassEvent {}
abstract class PassState {}
class PassLoading extends PassState {}
class PassLoaded extends PassState {
final List<PassModel> passes;
final double subtotal;
final double discountPercent;
final double total;
PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
}
class PassBloc extends Bloc<PassEvent, PassState> {
PassBloc() : super(PassLoading()) {
on<LoadPasses>((event, emit) {
final passes = [
PassModel(
title: "Melbourne",
imageUrl: "assets/images/city_melbourne.png",
duration: "2 days",
adults: 3,
kids: 3,
quantity: 2,
price: 49.50,
discount: 7.2,
),
];
final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
final discountPercent = passes.first.discount;
final total = subtotal - (subtotal * discountPercent / 100);
emit(PassLoaded(passes, subtotal, discountPercent, total));
});
}
}
// import 'package:flutter_bloc/flutter_bloc.dart';
// import '../model/pass_model.dart';
//
// abstract class PassEvent {}
// class LoadPasses extends PassEvent {}
//
// abstract class PassState {}
// class PassLoading extends PassState {}
// class PassLoaded extends PassState {
// final List<PassModel> passes;
// final double subtotal;
// final double discountPercent;
// final double total;
//
// PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
// }
//
// class PassBloc extends Bloc<PassEvent, PassState> {
// PassBloc() : super(PassLoading()) {
// on<LoadPasses>((event, emit) {
// final passes = [
// PassModel(
// title: "Melbourne",
// imageUrl: "assets/images/city_melbourne.png",
// duration: "2 days",
// adults: 3,
// kids: 3,
// quantity: 2,
// price: 49.50,
// discount: 7.2,
// ),
// ];
//
// final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
// final discountPercent = passes.first.discount;
// final total = subtotal - (subtotal * discountPercent / 100);
// emit(PassLoaded(passes, subtotal, discountPercent, total));
// });
// }
// }

View File

@@ -0,0 +1,207 @@
import 'dart:convert';
/// ---------- MAIN RESPONSE ----------
MyPassesCartModel myPassesCartModelFromJson(String str) =>
MyPassesCartModel.fromJson(json.decode(str));
String myPassesCartModelToJson(MyPassesCartModel data) =>
json.encode(data.toJson());
class MyPassesCartModel {
CartCity city;
List<CartItem> cartItems;
MyPassesCartModel({
required this.city,
required this.cartItems,
});
factory MyPassesCartModel.fromJson(Map<String, dynamic>? json) {
json ??= {};
return MyPassesCartModel(
city: CartCity.fromJson(json['city']),
cartItems: json['cartItems'] == null
? []
: List<Map<String, dynamic>>.from(json['cartItems'])
.map((e) => CartItem.fromJson(e))
.toList(),
);
}
Map<String, dynamic> toJson() => {
"city": city.toJson(),
"cartItems": cartItems.map((e) => e.toJson()).toList(),
};
}
/// ---------- CITY ----------
class CartCity {
int id;
String name;
CartCity({
required this.id,
required this.name,
});
factory CartCity.fromJson(Map<String, dynamic>? json) {
json ??= {};
return CartCity(
id: (json['id'] as num?)?.toInt() ?? 0,
name: json['name']?.toString() ?? "",
);
}
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
};
}
/// ---------- CART ITEM ----------
class CartItem {
int id;
String bookingNumber;
String cardMode;
int noOfDays;
int noOfAttractions;
int totalAdult;
int totalChild;
num baseAmount;
num totalTaxAmount;
num totalAmount;
String bookingStatus;
bool isForSelf;
String recipientFirstName;
String recipientLastName;
String recipientEmail;
String recipientPhone;
String recipientCity;
String recipientCountry;
String giftMessage;
bool isPaymentRequired;
int couponXid;
num couponDiscountAmount;
num couponDiscountPercent;
String paymentStatus;
String createdAt;
ItemCity city;
CartItem({
required this.id,
required this.bookingNumber,
required this.cardMode,
required this.noOfDays,
required this.noOfAttractions,
required this.totalAdult,
required this.totalChild,
required this.baseAmount,
required this.totalTaxAmount,
required this.totalAmount,
required this.bookingStatus,
required this.isForSelf,
required this.recipientFirstName,
required this.recipientLastName,
required this.recipientEmail,
required this.recipientPhone,
required this.recipientCity,
required this.recipientCountry,
required this.giftMessage,
required this.isPaymentRequired,
required this.couponXid,
required this.couponDiscountAmount,
required this.couponDiscountPercent,
required this.paymentStatus,
required this.createdAt,
required this.city,
});
factory CartItem.fromJson(Map<String, dynamic>? json) {
json ??= {};
return CartItem(
id: (json['id'] as num?)?.toInt() ?? 0,
bookingNumber: json['bookingNumber']?.toString() ?? "",
cardMode: json['cardMode']?.toString() ?? "",
noOfDays: (json['noOfDays'] as num?)?.toInt() ?? 0,
noOfAttractions: (json['noOfAttractions'] as num?)?.toInt() ?? 0,
totalAdult: (json['totalAdult'] as num?)?.toInt() ?? 0,
totalChild: (json['totalChild'] as num?)?.toInt() ?? 0,
baseAmount: json['baseAmount'] ?? 0,
totalTaxAmount: json['totalTaxAmount'] ?? 0,
totalAmount: json['totalAmount'] ?? 0,
bookingStatus: json['bookingStatus']?.toString() ?? "",
isForSelf: json['isForSelf'] ?? false,
recipientFirstName: json['recipientFirstName']?.toString() ?? "",
recipientLastName: json['recipientLastName']?.toString() ?? "",
recipientEmail: json['recipientEmail']?.toString() ?? "",
recipientPhone: json['recipientPhone']?.toString() ?? "",
recipientCity: json['recipientCity']?.toString() ?? "",
recipientCountry: json['recipientCountry']?.toString() ?? "",
giftMessage: json['giftMessage']?.toString() ?? "",
isPaymentRequired: json['isPaymentRequired'] ?? false,
couponXid: (json['couponXid'] as num?)?.toInt() ?? 0,
couponDiscountAmount: json['couponDiscountAmount'] ?? 0,
couponDiscountPercent: json['couponDiscountPercent'] ?? 0,
paymentStatus: json['paymentStatus']?.toString() ?? "",
createdAt: json['createdAt']?.toString() ?? "",
city: ItemCity.fromJson(json['city']),
);
}
Map<String, dynamic> toJson() => {
"id": id,
"bookingNumber": bookingNumber,
"cardMode": cardMode,
"noOfDays": noOfDays,
"noOfAttractions": noOfAttractions,
"totalAdult": totalAdult,
"totalChild": totalChild,
"baseAmount": baseAmount,
"totalTaxAmount": totalTaxAmount,
"totalAmount": totalAmount,
"bookingStatus": bookingStatus,
"isForSelf": isForSelf,
"recipientFirstName": recipientFirstName,
"recipientLastName": recipientLastName,
"recipientEmail": recipientEmail,
"recipientPhone": recipientPhone,
"recipientCity": recipientCity,
"recipientCountry": recipientCountry,
"giftMessage": giftMessage,
"isPaymentRequired": isPaymentRequired,
"couponXid": couponXid,
"couponDiscountAmount": couponDiscountAmount,
"couponDiscountPercent": couponDiscountPercent,
"paymentStatus": paymentStatus,
"createdAt": createdAt,
"city": city.toJson(),
};
}
/// ---------- ITEM CITY ----------
class ItemCity {
int id;
String cityName;
ItemCity({
required this.id,
required this.cityName,
});
factory ItemCity.fromJson(Map<String, dynamic>? json) {
json ??= {};
return ItemCity(
id: (json['id'] as num?)?.toInt() ?? 0,
cityName: json['cityName']?.toString() ?? "",
);
}
Map<String, dynamic> toJson() => {
"id": id,
"cityName": cityName,
};
}

View File

@@ -1,18 +1,39 @@
import 'package:flutter/foundation.dart';
import '../../localPreference/local_preference.dart';
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
import '../model/my_passes_cart_mode.dart';
class MyPassCartRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Check if user is logged in
Future<bool> isUserLoggedIn() async {
try {
final isLogin = await LocalPreference.getLogin();
if (kDebugMode) {
print('🔐 [REPO] User login status: $isLogin');
}
return isLogin;
} catch (e) {
if (kDebugMode) {
print('❌ [REPO] Error checking login status: $e');
}
return false;
}
}
/// Fetch pass cart data from local database
Future<Map<String, dynamic>?> fetchPassesCartByLocal() async {
try {
if (kDebugMode) {
print('🔄 [REPO] Fetching pass cart from local database...');
print('📄 [REPO] Fetching pass cart from local database...');
}
final passCartData = await LocalPreference.getPassCart();
if (passCartData != null) {
if (kDebugMode) {
print('✅ [REPO] Pass cart retrieved successfully');
@@ -32,4 +53,31 @@ class MyPassCartRepository {
rethrow;
}
}
/// Fetch pass cart data from API
Future<MyPassesCartModel> fetchMyPassesCart() async {
try {
if (kDebugMode) {
print('🌐 [REPO] Fetching pass cart from API...');
}
final cityID = await LocalPreference.getSelectedCityId();
final response = await _apiService.getApi(
url: '${ApiUrls.myPassesCart}?cityXid=$cityID',
);
if (kDebugMode) {
print('✅ [REPO] API response received');
}
return MyPassesCartModel.fromJson(response.data);
} catch (e) {
if (kDebugMode) {
print('❌ [REPO] Error fetching pass cart from API: $e');
}
rethrow;
}
}
}

View File

@@ -6,6 +6,8 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../add_details/add_details_view.dart';
import '../../checkout/widget/pass_purchase_details_bottomsheet.dart';
import '../../login/view/login_email_bottomsheet.dart';
import '../../common_packages/common_app_texts.dart';
import '../../localPreference/local_preference.dart';
@@ -24,12 +26,13 @@ class _MyPassesPageState extends State<MyPassesPage> {
// For coupon/discount management
String? appliedCouponCode;
double discountPercentage = 0.0;
bool isPurchaseDetailsConfirmed = false;
@override
void initState() {
super.initState();
// Fetch cart data when page loads
context.read<MyPassCartBloc>().add(const FetchPassCartEvent());
context.read<MyPassCartBloc>().add(const CheckLoginAndFetchEvent());
}
@override
@@ -38,36 +41,42 @@ class _MyPassesPageState extends State<MyPassesPage> {
builder: (context, state) {
if (state is MyPassCartLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is MyPassCartLoaded) {
final cartData = state.cartData;
}
// Extract data from cart
final String cityName = cartData['city_name'] as String? ?? '';
final String heroImage = cartData['hero_image'] as String? ?? '';
final String cardTypeName = cartData['card_type_name'] as String? ?? '';
final String cardDisplayName = cartData['card_display_name'] as String? ?? '';
final int themeColor = cartData['theme_color'] as int? ?? 0xFFF95FAF;
final int adultCount = cartData['adult_count'] as int? ?? 0;
final int childCount = cartData['child_count'] as int? ?? 0;
final double adultPrice = (cartData['adult_price'] as num?)?.toDouble() ?? 0.0;
final double childPrice = (cartData['child_price'] as num?)?.toDouble() ?? 0.0;
final int validityDuration = cartData['validity_duration'] as int? ?? 0;
final double totalPrice = (cartData['total_price'] as num?)?.toDouble() ?? 0.0;
final String? description = cartData['description'] as String?;
// ========== HANDLE API DATA (LOGGED IN USER) ==========
else if (state is MyPassCartApiLoaded) {
final apiCartData = state.apiCartData;
if (apiCartData.cartItems.isEmpty) {
return const Center(child: Text('Your cart is empty'));
}
// Get first cart item (you can modify to handle multiple items)
final cartItem = apiCartData.cartItems.first;
// Extract data from API cart item
final String cityName = cartItem.city.cityName;
final String heroImage = ''; // API doesn't have hero_image
final String cardTypeName = cartItem.cardMode;
final String cardDisplayName = cartItem.cardMode;
final int themeColor = 0xFFF95FAF;
final int adultCount = cartItem.totalAdult;
final int childCount = cartItem.totalChild;
final int validityDuration = cartItem.noOfDays;
final double totalPrice = cartItem.totalAmount.toDouble();
// Calculate pricing
final double subtotal = totalPrice;
final double discountAmount = subtotal * (discountPercentage / 100);
final double taxRate = 0.05; // 5% tax
final double subtotal = cartItem.baseAmount.toDouble();
final double discountAmount = cartItem.couponDiscountAmount.toDouble();
final double totalBeforeTax = subtotal - discountAmount;
final double taxAmount = totalBeforeTax * taxRate;
final double finalTotal = totalBeforeTax + taxAmount;
final double taxAmount = cartItem.totalTaxAmount.toDouble();
final double finalTotal = totalPrice;
// Determine if unlimited card
final bool isUnlimitedCard = cardTypeName == "unlimited_card";
final bool isUnlimitedCard = cardTypeName.toLowerCase().contains("unlimited");
final String validityLabel = isUnlimitedCard
? "$validityDuration Days"
: "$validityDuration Attractions";
: "${cartItem.noOfAttractions} Attractions";
return Column(
children: [
@@ -90,23 +99,7 @@ class _MyPassesPageState extends State<MyPassesPage> {
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: heroImage.isNotEmpty
? Image.network(
heroImage,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
);
},
)
: Image.asset(
child: Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
@@ -133,8 +126,460 @@ class _MyPassesPageState extends State<MyPassesPage> {
SizedBox(
width: MediaQuery.of(context).size.width * .5,
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Image.asset(
'assets/icons/adult.png',
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$adultCount ${adultCount == 1 ? 'adult' : 'adults'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
],
),
Row(
children: [
Image.asset(
'assets/icons/qty.png',
scale: 4,
),
SizedBox(width: 4.w),
Text.rich(
TextSpan(
children: [
TextSpan(
text: "Qty:",
style: TextStyle(
color: Color(0xFF8E8E8E),
fontSize: 12.sp,
),
),
TextSpan(
text: " ${adultCount + childCount}",
style: TextStyle(
color: Color(0xFF000000),
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 5.h),
Row(
children: [
Image.asset(
"assets/icons/kid.png",
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text: "$childCount ${childCount == 1 ? 'Kid' : 'Kids'}",
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(width: 53.w),
CustomText(
text: "\$${totalPrice.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
color: Color(0xFFF95F62),
),
],
),
],
),
],
),
Container(
width: 35.w,
height: 123.h,
decoration: BoxDecoration(
color: Color(themeColor),
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: "$cardDisplayName ",
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
),
),
],
),
),
),
),
),
],
),
),
SizedBox(height: 15.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: Color(0xFFBB474A).withOpacity(0.4),
width: 0.8,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: (cartItem.couponDiscountAmount > 0 || appliedCouponCode != null)
? "Coupon Applied (${(cartItem.couponDiscountAmount > 0 ? cartItem.couponDiscountPercent : discountPercentage).toStringAsFixed(0)}% off)"
: "Get 10% off on your first trip",
color: Color(0xFF262626),
size: 14.sp,
),
SizedBox(height: 7.h),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => AllCouponsBottomsheet(),
);
},
child: CustomText(
text: "View all coupons",
color: Color(0xFFF95F62),
size: 12,
),
),
SizedBox(width: 3.w),
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
],
),
],
),
const Spacer(),
// Only show Apply/Remove button if no API coupon is applied
if (cartItem.couponDiscountAmount == 0)
GestureDetector(
onTap: () {
setState(() {
if (appliedCouponCode == null) {
appliedCouponCode = "FIRST10";
discountPercentage = 10.0;
} else {
appliedCouponCode = null;
discountPercentage = 0.0;
}
});
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 10.h,
),
decoration: BoxDecoration(
border: Border.all(color: Color(0xFFF95F62)),
borderRadius: BorderRadius.circular(8.r),
),
child: CustomText(
text: appliedCouponCode != null ? "Remove" : "Apply",
color: Color(0xFFF95F62),
size: 14.sp,
),
),
),
],
),
),
SizedBox(height: 15.h),
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
// Calculate final discount and totals
Builder(
builder: (context) {
// Use API discount if available, otherwise use local discount
final effectiveDiscountAmount = cartItem.couponDiscountAmount > 0
? cartItem.couponDiscountAmount
: (subtotal * (discountPercentage / 100));
final effectiveDiscountPercent = cartItem.couponDiscountAmount > 0
? cartItem.couponDiscountPercent
: discountPercentage;
// Calculate tax on subtotal after discount
final subtotalAfterDiscount = subtotal - effectiveDiscountAmount;
final calculatedTax = subtotalAfterDiscount * 0.01; // 1% tax
final calculatedTotal = subtotalAfterDiscount + calculatedTax;
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Subtotal", size: 14.sp),
CustomText(
text: "\$${subtotal.toStringAsFixed(2)}",
size: 14.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 14.h),
if (effectiveDiscountAmount > 0) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Discount", size: 14.sp),
CustomText(
text: "-\$${effectiveDiscountAmount.toStringAsFixed(2)} (${effectiveDiscountPercent.toStringAsFixed(0)}%)",
size: 14.sp,
weight: FontWeight.w500,
color: Colors.green,
),
],
),
SizedBox(height: 14.h),
],
DashedDivider(
color: Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: 'Total', size: 14.sp),
SizedBox(height: 4.h),
CustomText(
text: "Including \$${calculatedTax.toStringAsFixed(2)} in taxes",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
],
),
),
CustomText(
text: "\$${calculatedTotal.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
),
],
),
SizedBox(height: 150.h),
FutureBuilder<bool>(
future: LocalPreference.getLogin(),
builder: (context, snapshot) {
final isLoggedIn = snapshot.data ?? false;
return CustomFilledButton(
onTap: () async {
if (isLoggedIn) {
if (isPurchaseDetailsConfirmed) {
print("✅ Ready to pay: \$${calculatedTotal.toStringAsFixed(2)}");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Payment integration pending'),
backgroundColor: Colors.orange,
),
);
} else {
final result = await PassPurchaseBottomSheet.show(
context,
bookingId: cartItem.id,
);
if (result == 'success') {
setState(() {
isPurchaseDetailsConfirmed = true;
});
} else if (result == 'gift') {
final giftResult = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => AddDetailsView(bookingId: cartItem.id),
),
);
if (giftResult == 'success') {
setState(() {
isPurchaseDetailsConfirmed = true;
});
}
}
}
} else {
Navigator.pop(context);
showModalBottomSheet(
backgroundColor: Colors.white,
context: context,
isScrollControlled: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => const LoginEmailBottomsheet(),
);
}
},
width: double.infinity,
label: isLoggedIn
? (isPurchaseDetailsConfirmed
? "Pay \$${calculatedTotal.toStringAsFixed(2)}"
: "Checkout")
: "Login to Checkout",
);
},
),
SizedBox(height: 25.h),
],
);
},
),
],
);
}
// ========== HANDLE LOCAL DATA (NOT LOGGED IN) ==========
else if (state is MyPassCartLoaded) {
final cartData = state.cartData;
// Extract data from cart
final String cityName = cartData['city_name'] as String? ?? '';
final String heroImage = cartData['hero_image'] as String? ?? '';
final String cardTypeName = cartData['card_type_name'] as String? ?? '';
final String cardDisplayName = cartData['card_display_name'] as String? ?? '';
final int themeColor = cartData['theme_color'] as int? ?? 0xFFF95FAF;
final int adultCount = cartData['adult_count'] as int? ?? 0;
final int childCount = cartData['child_count'] as int? ?? 0;
final double adultPrice = (cartData['adult_price'] as num?)?.toDouble() ?? 0.0;
final double childPrice = (cartData['child_price'] as num?)?.toDouble() ?? 0.0;
final int validityDuration = cartData['validity_duration'] as int? ?? 0;
final double totalPrice = (cartData['total_price'] as num?)?.toDouble() ?? 0.0;
final String? description = cartData['description'] as String?;
// Calculate pricing
final double subtotal = totalPrice;
final double discountAmount = subtotal * (discountPercentage / 100);
final double totalBeforeTax = subtotal - discountAmount;
final double taxAmount = 2;
final double finalTotal = totalBeforeTax + taxAmount;
// Determine if unlimited card
final bool isUnlimitedCard = cardTypeName == "unlimited_card";
final String validityLabel = isUnlimitedCard
? "$validityDuration Days"
: "$validityDuration Attractions";
return Column(
children: [
SizedBox(height: 22.h),
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: Color(themeColor).withOpacity(0.2),
),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: heroImage.isNotEmpty
? Image.network(
heroImage,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
);
},
)
: Image.asset(
"assets/images/card_banner.png",
scale: 4,
width: 105.w,
height: 123.h,
fit: BoxFit.cover,
),
),
SizedBox(width: 6.66.w),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: cityName,
weight: FontWeight.w500,
size: 16.sp,
),
SizedBox(height: 5.h),
CustomText(
text: validityLabel,
color: Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(height: 5.h),
SizedBox(
width: MediaQuery.of(context).size.width * .5,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
@@ -232,13 +677,6 @@ class _MyPassesPageState extends State<MyPassesPage> {
fontSize: 16.sp,
),
),
// TextSpan(
// text: "Card",
// style: TextStyle(
// color: Colors.white,
// fontSize: 12.sp,
// ),
// ),
],
),
),
@@ -402,42 +840,10 @@ class _MyPassesPageState extends State<MyPassesPage> {
],
),
SizedBox(height: 150.h),
// FutureBuilder for login check
FutureBuilder<bool>(
future: LocalPreference.getLogin(),
builder: (context, snapshot) {
final isLoggedIn = snapshot.data ?? false;
return CustomFilledButton(
onTap: () {
if (!isLoggedIn) {
showModalBottomSheet(
backgroundColor: Colors.white,
context: context,
isScrollControlled: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => const LoginEmailBottomsheet(),
);
} else {
// Handle checkout logic for logged in user
// You can navigate to checkout or payment screen
print("✅ User is logged in, proceed to checkout");
}
},
width: double.infinity,
label: isLoggedIn ? "Checkout" : "Login to Checkout",
);
},
),
SizedBox(height: 25.h),
],
);
} else if (state is MyPassCartEmpty) {
}
else if (state is MyPassCartEmpty) {
return Center(
child: Column(
children: [

View File

@@ -197,6 +197,15 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
ConfirmPaymentEvent event,
Emitter<CheckoutState> emit,
) async {
// 🔒 GUARD: Prevent duplicate confirmation calls
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
if (currentState.hasConfirmationBeenSent) {
print('⚠️ [CHECKOUT BLOC] Payment confirmation already sent. Ignoring duplicate call.');
return;
}
}
// Show loading state
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
@@ -204,6 +213,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
isConfirmingPayment: true,
confirmationError: null,
isPaymentConfirmed: false,
hasConfirmationBeenSent: true, // 🔒 Mark as sent
));
} else {
emit(CheckoutPaymentConfirmingState());
@@ -239,6 +249,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
isConfirmingPayment: false,
isPaymentConfirmed: false,
confirmationError: e.toString(),
hasConfirmationBeenSent: false, // 🔓 Reset on error to allow retry
));
} else {
emit(CheckoutPaymentConfirmationErrorState(

View File

@@ -25,6 +25,7 @@ class CheckoutCouponsLoadedState extends CheckoutState {
final bool isPaymentConfirmed;
final String? confirmationError;
final Map<String, dynamic>? bookingDetails; // Full booking response after confirmation
final bool hasConfirmationBeenSent; // 🔒 Prevent duplicate confirmation calls
CheckoutCouponsLoadedState({
required this.coupons,
@@ -39,6 +40,7 @@ class CheckoutCouponsLoadedState extends CheckoutState {
this.isPaymentConfirmed = false,
this.confirmationError,
this.bookingDetails,
this.hasConfirmationBeenSent = false,
});
CheckoutCouponsLoadedState copyWith({
@@ -56,6 +58,7 @@ class CheckoutCouponsLoadedState extends CheckoutState {
String? confirmationError,
bool clearClientSecret = false,
Map<String, dynamic>? bookingDetails,
bool? hasConfirmationBeenSent,
}) {
return CheckoutCouponsLoadedState(
coupons: coupons ?? this.coupons,
@@ -70,6 +73,7 @@ class CheckoutCouponsLoadedState extends CheckoutState {
confirmationError: confirmationError,
clientSecret: clearClientSecret ? null : (clientSecret ?? this.clientSecret),
bookingDetails: bookingDetails ?? this.bookingDetails,
hasConfirmationBeenSent: hasConfirmationBeenSent ?? this.hasConfirmationBeenSent,
);
}
}

View File

@@ -34,12 +34,12 @@ class PassPurchaseDetailsRepository {
// Request body
final requestBody = {
'isForSelf': isForSelf,
'recipientName': recipientFirstName ?? '',
// 'recipientLastName': recipientLastName ?? '',
'recipientFirstName': recipientFirstName ?? '',
'recipientLastName': recipientLastName ?? '',
'recipientEmail': recipientEmail ?? '',
'recipientPhone': recipientPhone ?? '',
// 'city': city ?? '',
// 'country': country ?? '',
'recipientCity': city ?? '',
'recipientCountry': country ?? '',
};
log('📦 Request Body: $requestBody');

View File

@@ -13,7 +13,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../StripePayment/view/stripe_payment.dart';
import '../../add_details/add_details_view.dart';
import '../../buy_a_pass/models/checkout_model.dart';
import '../../itinerary_creation/bloc/get_itinerary_bloc.dart';
import '../../localPreference/local_preference.dart';
import '../../my_pass/blocs/myPasses/my_passes_bloc.dart';
import '../../my_pass/blocs/myPasses/my_passes_event.dart';
import '../widget/pass_purchase_details_bottomsheet.dart';
import '../repository/all_coupons_repository.dart';
import '../repository/checkout_repository.dart';
@@ -101,7 +104,7 @@ class _CheckoutViewState extends State<CheckoutView> {
}
}
class _CheckoutContent extends StatelessWidget {
class _CheckoutContent extends StatefulWidget {
final CheckoutData checkoutData;
final int bookingId;
final bool isPurchaseDetailsConfirmed;
@@ -114,6 +117,12 @@ class _CheckoutContent extends StatelessWidget {
required this.onPurchaseDetailsChanged,
});
@override
State<_CheckoutContent> createState() => _CheckoutContentState();
}
class _CheckoutContentState extends State<_CheckoutContent> {
bool _hasHandledPaymentResult = false;
/// 🆕 Handle payment flow with client secret
/// 🆕 Handle payment flow with client secret - SIMPLIFIED VERSION
Future<void> _handlePaymentFlow(BuildContext context, String clientSecret, int bookingId,double finalTotal) async {
@@ -165,7 +174,10 @@ class _CheckoutContent extends StatelessWidget {
await Future.delayed(const Duration(milliseconds: 500));
// Navigate to home after successful payment
Navigator.of(context).popUntil((route) => route.isFirst);
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Payment confirmed successfully!'),
@@ -181,15 +193,21 @@ class _CheckoutContent extends StatelessWidget {
listener: (context, state) {
// 🆕 Listen for payment initiation success
if (state is CheckoutCouponsLoadedState) {
// Check if clientSecret is available (payment initiated)
if (state.clientSecret != null && state.clientSecret!.isNotEmpty) {
// 🔒 CHECK: Prevent duplicate payment flow initiation
if (state.clientSecret != null &&
state.clientSecret!.isNotEmpty &&
!_hasHandledPaymentResult) { // 🔒 Only proceed if not already handled
// 🔒 MARK: Set flag immediately to prevent re-entry
_hasHandledPaymentResult = true;
// ✅ Calculate finalTotal here
double discountPercentage = 0.0;
if (state.appliedCoupon != null) {
discountPercentage = state.appliedCoupon!.discountPercent.toDouble();
}
final num subtotal = checkoutData.totalPrice;
final num subtotal = widget.checkoutData.totalPrice; // Changed to widget.
final double discountAmount = subtotal * (discountPercentage / 100);
final double totalBeforeTax = subtotal - discountAmount;
final double taxAmount = 2;
@@ -200,7 +218,7 @@ class _CheckoutContent extends StatelessWidget {
_handlePaymentFlow(
context,
state.clientSecret!,
state.bookingId ?? bookingId,
state.bookingId ?? widget.bookingId,
finalTotal, // ✅ Pass the calculated finalTotal
);
});
@@ -263,7 +281,7 @@ class _CheckoutContent extends StatelessWidget {
isConfirmingPayment = state.isConfirmingPayment;
}
final num subtotal = checkoutData.totalPrice;
final num subtotal = widget.checkoutData.totalPrice;
final double discountAmount = subtotal * (discountPercentage / 100);
// final double taxRate = 0.05; // 5% tax
final double totalBeforeTax = subtotal - discountAmount;
@@ -307,7 +325,7 @@ class _CheckoutContent extends StatelessWidget {
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: checkoutData.themeColor.withOpacity(0.2),
color: widget.checkoutData.themeColor.withOpacity(0.2),
),
borderRadius: BorderRadius.circular(8.r),
),
@@ -322,9 +340,9 @@ class _CheckoutContent extends StatelessWidget {
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: checkoutData.heroImage.isNotEmpty
child: widget.checkoutData.heroImage.isNotEmpty
? Image.network(
checkoutData.heroImage,
widget.checkoutData.heroImage,
width: 105.w,
height: 140.h,
fit: BoxFit.cover,
@@ -344,7 +362,7 @@ class _CheckoutContent extends StatelessWidget {
height: 24.w,
child: CircularProgressIndicator(
strokeWidth: 2,
color: checkoutData.themeColor,
color: widget.checkoutData.themeColor,
),
),
),
@@ -363,7 +381,7 @@ class _CheckoutContent extends StatelessWidget {
children: [
// City Name
CustomText(
text: checkoutData.cityName,
text: widget.checkoutData.cityName,
weight: FontWeight.w500,
size: 16.sp,
),
@@ -371,7 +389,7 @@ class _CheckoutContent extends StatelessWidget {
// Validity (Days or Attractions)
CustomText(
text: checkoutData.validityLabel,
text: widget.checkoutData.validityLabel,
color: const Color(0xFF8E8E8E),
size: 12.sp,
),
@@ -385,7 +403,7 @@ class _CheckoutContent extends StatelessWidget {
MainAxisAlignment.spaceBetween,
children: [
// Adults
if (checkoutData.adultCount > 0)
if (widget.checkoutData.adultCount > 0)
Row(
children: [
Image.asset(
@@ -395,7 +413,7 @@ class _CheckoutContent extends StatelessWidget {
SizedBox(width: 4.w),
CustomText(
text:
"${checkoutData.adultCount} adult${checkoutData.adultCount > 1 ? 's' : ''}",
"${widget.checkoutData.adultCount} adult${widget.checkoutData.adultCount > 1 ? 's' : ''}",
color: const Color(0xFF8E8E8E),
size: 12.sp,
),
@@ -408,7 +426,7 @@ class _CheckoutContent extends StatelessWidget {
Row(
children: [
// Children
if (checkoutData.childCount > 0) ...[
if (widget.checkoutData.childCount > 0) ...[
Image.asset(
"assets/icons/kid.png",
scale: 4,
@@ -416,7 +434,7 @@ class _CheckoutContent extends StatelessWidget {
SizedBox(width: 4.w),
CustomText(
text:
"${checkoutData.childCount} Kid${checkoutData.childCount > 1 ? 's' : ''}",
"${widget.checkoutData.childCount} Kid${widget.checkoutData.childCount > 1 ? 's' : ''}",
color: const Color(0xFF8E8E8E),
size: 12.sp,
),
@@ -429,7 +447,7 @@ class _CheckoutContent extends StatelessWidget {
text: "\$${subtotal.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
color: checkoutData.themeColor,
color: widget.checkoutData.themeColor,
),
],
),
@@ -443,7 +461,7 @@ class _CheckoutContent extends StatelessWidget {
width: 35.w,
height: 140.h,
decoration: BoxDecoration(
color: checkoutData.themeColor,
color: widget.checkoutData.themeColor,
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
@@ -453,7 +471,7 @@ class _CheckoutContent extends StatelessWidget {
quarterTurns: -1,
child: Center(
child: Text(
checkoutData.cardDisplayName,
widget.checkoutData.cardDisplayName,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
@@ -550,7 +568,7 @@ class _CheckoutContent extends StatelessWidget {
);
context.read<CheckoutBloc>().add(
ApplyCouponToBackendEvent(
bookingId: bookingId,
bookingId: widget.bookingId,
couponCode: coupon.couponCode,
),
);
@@ -586,13 +604,13 @@ class _CheckoutContent extends StatelessWidget {
onTap: () {
if (appliedCoupon != null) {
context.read<CheckoutBloc>().add(
RemoveCouponEvent(bookingId: bookingId),
RemoveCouponEvent(bookingId: widget.bookingId),
);
} else if (state.coupons.isNotEmpty) {
// Apply coupon via backend API
context.read<CheckoutBloc>().add(
ApplyCouponToBackendEvent(
bookingId: bookingId,
bookingId: widget.bookingId,
couponCode: state.coupons[0].couponCode,
),
);
@@ -717,32 +735,32 @@ class _CheckoutContent extends StatelessWidget {
? () {} // Empty callback when disabled
: () async {
if (isLoggedIn) {
if (isPurchaseDetailsConfirmed) {
if (widget.isPurchaseDetailsConfirmed) {
// 🆕 Initiate payment flow
context.read<CheckoutBloc>().add(
InitiatePaymentEvent(
bookingId: bookingId),
bookingId: widget.bookingId),
);
} else {
// Show purchase details bottom sheet
final result = await PassPurchaseBottomSheet.show(
context, bookingId: bookingId);
context, bookingId: widget.bookingId);
// ✅ Handle 'Buy for Myself' - user submitted details
if (result == 'success') {
onPurchaseDetailsChanged(true);
widget.onPurchaseDetailsChanged(true);
}
// ✅ Handle 'Gift the Pass' - navigate to AddDetailsView
else if (result == 'gift') {
final giftResult = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => AddDetailsView(bookingId: bookingId),
builder: (_) => AddDetailsView(bookingId: widget.bookingId),
),
);
// If gift details were successfully submitted, mark as confirmed
if (giftResult == 'success') {
onPurchaseDetailsChanged(true);
widget.onPurchaseDetailsChanged(true);
}
}
}
@@ -764,7 +782,7 @@ class _CheckoutContent extends StatelessWidget {
},
width: double.infinity,
label: isLoggedIn
? (isPurchaseDetailsConfirmed
? (widget.isPurchaseDetailsConfirmed
? (isInitiatingPayment || isConfirmingPayment
? "Processing..."
: "Pay \$${finalTotal.toStringAsFixed(2)}")

View File

@@ -47,12 +47,12 @@ class _PassPurchaseContent extends StatelessWidget {
Navigator.of(context).pop('success');
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Details submitted successfully!'),
backgroundColor: Color(0xffF95F62),
),
);
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text('Details submitted successfully!'),
// backgroundColor: Color(0xffF95F62),
// ),
// );
}
// Handle API submission error

View File

@@ -1,3 +1,3 @@
class CommonAppText {
static const String selectiveCard = "Selective";
static const String selectiveCard = "Flexi";
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class DashedBorderPainter extends CustomPainter {
final Color color;
final double strokeWidth;
final double gap;
final double dashWidth;
final double radius;
DashedBorderPainter({
required this.color,
this.strokeWidth = 1.5,
this.gap = 6,
this.dashWidth = 6,
this.radius = 16,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke;
final rRect = RRect.fromRectAndRadius(
Offset.zero & size,
Radius.circular(radius),
);
final path = Path()..addRRect(rRect);
final dashPath = Path();
for (final metric in path.computeMetrics()) {
double distance = 0;
while (distance < metric.length) {
dashPath.addPath(
metric.extractPath(distance, distance + dashWidth),
Offset.zero,
);
distance += dashWidth + gap;
}
}
canvas.drawPath(dashPath, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -1,3 +1,4 @@
import 'package:citycards_customer/add_details/add_details_view.dart';
import 'package:citycards_customer/attraction_details/views/attraction_details_view.dart';
import 'package:citycards_customer/attractions/models/attraction_model.dart';
@@ -14,6 +15,8 @@ import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_s
import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_view.dart';
import 'package:citycards_customer/itinerary_creation/views/magic_itinerary_empty_view.dart';
import 'package:citycards_customer/itinerary_creation/views/magic_itinerary_view.dart';
import 'package:citycards_customer/my_pass/views/pass_attractions_page_view.dart';
import 'package:citycards_customer/my_pass/views/search_pass_offers_with_listing.dart';
import 'package:citycards_customer/offer_pass_detail/offer_pass_detail_view.dart';
import 'package:citycards_customer/search_offers/bloc/search_offers_listing_bloc.dart';
import 'package:citycards_customer/search_offers/view/search_offers_with_listing.dart';
@@ -27,6 +30,11 @@ 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 '../my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart';
import '../my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart';
import '../my_pass/repository/my_passes_attractions_repository.dart';
import '../my_pass/repository/my_passes_offers_repository.dart';
import '../my_pass/views/pass_attraction_details_view.dart';
import '../profile/view/contact_us/contact_us_view.dart';
import '../profile/view/edit_profile/edit_profile_view.dart';
import '../profile/view/faq/faq_view.dart';
@@ -69,6 +77,24 @@ class AppRouter {
case RouteConstants.attractionsPage:
final args = settings.arguments as String;
return MaterialPageRoute(builder: (_) => AttractionsPage(source: args));
case RouteConstants.passAttractionsPage:
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
final int cityId = args['cityId'] as int;
final String source = args['source'] as String;
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => MyPassesAttractionsBloc(
repository: MyPassesAttractionsRepository(),
),
child: PassAttractionsPage(
cityXid: cityId,
source: source,
),
);
},
);
case RouteConstants.profile:
return MaterialPageRoute(
builder: (_) {
@@ -149,10 +175,18 @@ class AppRouter {
);
case RouteConstants.attractionDetails:
final attractionId = settings.arguments as Attraction;
final attractionId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView(attractionId: attractionId.id);
return AttractionDetailsView(attractionId: attractionId);
},
);
case RouteConstants.passAttractionDetails:
final attractionID = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView(attractionId: attractionID);
},
);
@@ -170,6 +204,7 @@ class AppRouter {
builder: (_) => CheckoutView(bookingId: bookingId),
);
case RouteConstants.cartPage:
return MaterialPageRoute(
builder: (_) {
@@ -186,6 +221,16 @@ class AppRouter {
);
},
);
case RouteConstants.searchPassOffer:
final int cityId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository()),
child: PassOffersScreen(cityId: cityId),
);
},
);
case RouteConstants.addDetails:
final bookingId = settings.arguments as int;
@@ -196,6 +241,7 @@ class AppRouter {
},
);
case RouteConstants.createAcct:
final email = settings.arguments as String;
@@ -232,9 +278,12 @@ class AppRouter {
final offerId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) => OffersDetailsView(offerId: offerId),
builder: (_) => OffersDetailsView(
offerId: offerId,
),
);
case RouteConstants.registeredUserHome:
return MaterialPageRoute(
builder: (_) {

View File

@@ -0,0 +1,6 @@
import 'package:flutter/material.dart';
class GlobalKeys {
static final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
GlobalKey<ScaffoldMessengerState>();
}

View File

@@ -2,6 +2,9 @@ import 'package:citycards_customer/attractions/models/attraction_model.dart';
import 'package:citycards_customer/core/route_constants.dart';
import 'package:citycards_customer/home/views/registered_user_home_page.dart';
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
import 'package:citycards_customer/my_pass/views/pass_attraction_details_view.dart';
import 'package:citycards_customer/my_pass/views/pass_attractions_page_view.dart';
import 'package:citycards_customer/my_pass/views/search_pass_offers_with_listing.dart';
import 'package:citycards_customer/postcard/views/add_filter_step_page_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -16,12 +19,19 @@ import '../itinerary_creation/bloc/itinerary_detail_bloc.dart';
import '../itinerary_creation/bloc/itinerary_steps_selection_bloc.dart';
import '../itinerary_creation/views/itinerary_creation_view.dart';
import '../itinerary_creation/views/magic_itinerary_view.dart';
import '../my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart';
import '../my_pass/blocs/myPassesDetails/my_passes_details_bloc.dart';
import '../my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart';
import '../my_pass/repository/my_passes_attractions_repository.dart';
import '../my_pass/repository/my_passes_details_repository.dart';
import '../my_pass/repository/my_passes_offers_repository.dart';
import '../my_pass/views/booking_page_view.dart';
import '../my_pass/views/booking_successful_page_view.dart';
import '../my_pass/views/qr_pass_page_view.dart';
import '../my_pass/views/pass_details_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 '../profile/view/privacy/privacy_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';
@@ -54,12 +64,38 @@ Widget buildOffstageNavigator(
return MaterialPageRoute(
builder: (_) => AttractionsPage(source: args),
);
case RouteConstants.passAttractionsPage:
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
final int cityId = args['cityId'] as int;
final String source = args['source'] as String;
case RouteConstants.attractionDetails:
final attraction = settings.arguments as Attraction;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView(attractionId: attraction.id);
return BlocProvider(
create: (_) => MyPassesAttractionsBloc(
repository: MyPassesAttractionsRepository(),
),
child: PassAttractionsPage(
cityXid: cityId,
source: source,
),
);
},
);
case RouteConstants.attractionDetails:
final attractionID = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView(attractionId: attractionID);
},
);
case RouteConstants.passAttractionDetails:
final attractionID = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return PassAttractionDetailsView(attractionId: attractionID);
},
);
@@ -99,6 +135,23 @@ Widget buildOffstageNavigator(
);
},
);
case RouteConstants.searchPassOffer:
final int cityId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository()),
child: PassOffersScreen(cityId: cityId),
);
},
);
case RouteConstants.privacyPolicy:
return MaterialPageRoute(
builder: (_) {
return const PrivacyPolicyPage();
},
);
// 🔹 Upload Photo Page (start of postcard creation flow)
case RouteConstants.uploadPhotoPage:
@@ -124,12 +177,14 @@ Widget buildOffstageNavigator(
);
case RouteConstants.qrPage:
final bookingId = settings.arguments as int;
return MaterialPageRoute(
builder: (context) {
final previousBloc = BlocProvider.of<MyPassBloc>(context);
return BlocProvider.value(
value: previousBloc,
child: const QrPassView(),
return BlocProvider(
create: (context) => MyPassesDetailsBloc(
repository: MyPassesDetailsRepository(),
),
child: PassDetailsView(bookingId: bookingId),
);
},
);

View File

@@ -1,4 +1,6 @@
class RouteConstants {
static const String intro = '/intro';
static const String splash = '/splash';
@@ -7,6 +9,7 @@ class RouteConstants {
static const String home = '/home';
static const String registeredUserHome = '/registeredUserHome';
static const String attractionsPage = "/attractions";
static const String passAttractionsPage = "/passAttractionsPage";
static const String postCardPage = "/postcards";
static const String uploadPhotoPage = "/uploadPhoto";
static const String addFilterPage = "/addFilter";
@@ -36,20 +39,24 @@ class RouteConstants {
/**************************** Attraction Page *****************************************/
static const String attractionDetails ='/attractionDetails';
static const String passAttractionDetails ='/passAttractionDetails';
/**************************** By Pass Page Page *****************************************/
static const String buyPass = '/buyPass';
static const String checkout = '/checkout';
static const String searchOffer = '/searchOffer';
static const String searchPassOffer = '/searchPassOffer';
static const String createAcct = '/createAcct';
static const String addDetails = '/addDetails';
static const String offerPassDetail = "/offerPassDetail";
/************************** My card page ***************************************/
static const String cartPage = '/cartPage';
static const String yourItinerary = '/yourItinerary';
static const String qrPage = '/qrPage';
static const String makeBooking = '/makeBooking';
static const String bookingSuccessful = '/bookingSuccessful';

View File

@@ -28,14 +28,21 @@ class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
mobileNumber: event.mobileNumber,
address1: event.address1,
address2: event.address2,
city: event.city,
state: event.state,
country: event.country,
postalCode: event.postalCode,
);
await LocalPreference.setLogin(true);
// ✅ FIX: Parse directly from response, just like verify OTP
final userModel = UserRegisteredModel.fromJson(response);
final userModel = UserRegisteredModel.fromJson(response['data'] ?? {});
await LocalPreference.setTokens(
accessToken: userModel.accessToken,
refreshToken: userModel.refreshToken,
refreshTokenMaxAge: userModel.refreshTokenMaxAge,
);
await LocalPreference.setUserDetails(
userId: userModel.user.id,
firstName: userModel.user.firstName,
@@ -45,10 +52,12 @@ class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
role: userModel.user.role,
roleId: userModel.user.roleId,
);
await LocalPreference.setProfileImage(userModel.user.profileImage);
emit(CreateAccountSuccess(
message: response['message'] ?? 'Account created successfully',
userData: response['data'] ?? {},
message: 'Account created successfully',
userData: response,
));
} catch (e) {
emit(CreateAccountFailure(

View File

@@ -14,6 +14,10 @@ class CreateAccountSubmitted extends CreateAccountEvent {
final String mobileNumber;
final String address1;
final String address2;
final String city;
final String state;
final String country;
final String postalCode;
const CreateAccountSubmitted({
required this.firstName,
@@ -22,6 +26,10 @@ class CreateAccountSubmitted extends CreateAccountEvent {
required this.mobileNumber,
required this.address1,
required this.address2,
required this.city,
required this.state,
required this.country,
required this.postalCode,
});
@override
@@ -32,6 +40,10 @@ class CreateAccountSubmitted extends CreateAccountEvent {
mobileNumber,
address1,
address2,
city,
state,
country,
postalCode,
];
}

View File

@@ -11,17 +11,25 @@ class CreateAccountRepository {
required String mobileNumber,
required String address1,
required String address2,
required String city,
required String state,
required String country,
required String postalCode,
}) async {
try {
final response = await _apiServices.postApi(
url: ApiUrls.createAccount,
data: {
'firstName': firstName,
'lastName': lastName,
'emailAddress': emailAddress,
'mobileNumber': mobileNumber,
'address1': address1,
'address2': address2,
"firstName": firstName,
"lastName": lastName,
"emailAddress": emailAddress,
"mobileNumber": mobileNumber,
"address1": address1,
"address2": address2,
"city": city,
"state": state,
"country": country,
"postalCode": postalCode,
},
);

View File

@@ -5,7 +5,13 @@ import 'package:citycards_customer/common_packages/custom_textfield.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/route_constants.dart';
import 'package:citycards_customer/my_pass/blocs/myPasses/my_passes_event.dart';
import '../../itinerary_creation/bloc/get_itinerary_bloc.dart';
import '../../localPreference/local_preference.dart';
import '../../my_pass/blocs/myPasses/my_passes_bloc.dart';
import '../../postcard/blocs/myPostCards/my_postcard_bloc.dart';
import '../../postcard/blocs/myPostCards/my_postcard_event.dart';
import '../../profile/bloc/profile/profile_bloc.dart';
import '../../profile/bloc/profile/profile_event.dart';
import '../bloc/create_account_bloc.dart';
@@ -13,25 +19,39 @@ import '../bloc/create_account_event.dart';
import '../bloc/create_account_state.dart';
import '../repository/create_account_repository.dart';
class CreateAccountView extends StatelessWidget {
class CreateAccountView extends StatefulWidget {
final String email;
CreateAccountView({super.key, required this.email});
const CreateAccountView({super.key, required this.email});
@override
State<CreateAccountView> createState() => _CreateAccountViewState();
}
class _CreateAccountViewState extends State<CreateAccountView> {
final TextEditingController firstNameController = TextEditingController();
final TextEditingController lastNameController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
final TextEditingController addressController = TextEditingController();
final TextEditingController cityController = TextEditingController();
final TextEditingController postalController = TextEditingController();
String? selectedState;
String? selectedCountry;
void _submitForm(BuildContext context) {
if (firstNameController.text.trim().isEmpty ||
lastNameController.text.trim().isEmpty ||
emailController.text.trim().isEmpty ||
phoneController.text.trim().isEmpty ||
addressController.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Please fill all fields')));
addressController.text.trim().isEmpty ||
cityController.text.trim().isEmpty ||
selectedState == null ||
selectedCountry == null ||
postalController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please fill all fields')),
);
return;
}
@@ -43,27 +63,49 @@ class CreateAccountView extends StatelessWidget {
mobileNumber: phoneController.text.trim(),
address1: addressController.text.trim(),
address2: '',
city: cityController.text.trim(),
state: selectedState!,
country: selectedCountry!,
postalCode: postalController.text.trim(),
),
);
}
@override
void dispose() {
firstNameController.dispose();
lastNameController.dispose();
emailController.dispose();
phoneController.dispose();
addressController.dispose();
cityController.dispose();
postalController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
emailController.text = email;
emailController.text = widget.email;
return BlocProvider(
create: (context) =>
CreateAccountBloc(repository: CreateAccountRepository()),
child: BlocListener<CreateAccountBloc, CreateAccountState>(
listener: (ctx, state) async {
if (state is CreateAccountSuccess) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.message)));
await LocalPreference.setLogin(true);
final userId = await LocalPreference.getUserId();
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
context.read<MyPostCardBloc>().add(CheckLoginStatus());
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
// context.read<MyPostCardBloc>().add(FetchDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.message)));
} else if (state is CreateAccountFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -168,14 +210,157 @@ class CreateAccountView extends StatelessWidget {
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Address 1",
label: "Address",
hint: "Enter address manually or tap to search",
controller: addressController,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "City",
hint: "Enter your city",
controller: cityController,
),
),
// State Dropdown
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "State", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedState,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select state",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedState = value;
});
},
items: [
"New South Wales",
"Victoria",
"Queensland",
"South Australia",
"Western Australia",
"Tasmania",
"Northern Territory",
"Australian Capital Territory"
].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
// Country Dropdown
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Country", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountry,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select country",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedCountry = value;
});
},
items: ["Australia"].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Postal Code",
hint: "Enter postal / zip code",
controller: postalController,
keyboardType: TextInputType.number,
),
),
SizedBox(height: 20.h),
BlocBuilder<CreateAccountBloc, CreateAccountState>(
builder: (context, state) {
if (state is CreateAccountLoading) {

View File

@@ -6,7 +6,8 @@ class CitySelectionResponse {
factory CitySelectionResponse.fromJson(Map<String, dynamic> json) {
return CitySelectionResponse(
cities: (json['cities'] as List<dynamic>?)
?.map((city) => CitySelection.fromJson(city as Map<String, dynamic>))
?.map((city) =>
CitySelection.fromJson(city as Map<String, dynamic>))
.toList() ??
[],
);
@@ -20,33 +21,54 @@ class CitySelectionResponse {
}
class CitySelection {
// 🔹 EXISTING FIELDS (UNCHANGED)
final int id;
final String cityName;
final String bannerImage;
// 🔹 NEW FIELDS (ADDED ONLY)
final String cityIconPath;
final CityIcon? icon;
CitySelection({
required this.id,
required this.cityName,
required this.bannerImage,
// 🔹 ADDED
required this.cityIconPath,
required this.icon,
});
factory CitySelection.fromJson(Map<String, dynamic> json) {
return CitySelection(
// 🔹 EXISTING
id: json['id'] as int? ?? 0,
cityName: json['cityName'] as String? ?? '',
bannerImage: json['bannerImage'] as String? ?? '',
// 🔹 ADDED
cityIconPath: json['cityIconPath'] as String? ?? '',
icon: json['icon'] != null
? CityIcon.fromJson(json['icon'] as Map<String, dynamic>)
: null,
);
}
Map<String, dynamic> toJson() {
return {
// 🔹 EXISTING
'id': id,
'cityName': cityName,
'bannerImage': bannerImage,
// 🔹 ADDED
'cityIconPath': cityIconPath,
'icon': icon?.toJson(),
};
}
// Helper method to get the image URL with fallback
// 🔹 EXISTING METHODS (UNCHANGED)
String getImageUrl() {
if (bannerImage.isEmpty || !bannerImage.startsWith('http')) {
return 'assets/images/card_banner.png';
@@ -54,8 +76,26 @@ class CitySelection {
return bannerImage;
}
// Helper method to check if image is network image
bool isNetworkImage() {
return bannerImage.isNotEmpty && bannerImage.startsWith('http');
}
}
// 🔹 NEW MODEL (REQUIRED FOR icon.svg)
class CityIcon {
final String svg;
CityIcon({required this.svg});
factory CityIcon.fromJson(Map<String, dynamic> json) {
return CityIcon(
svg: json['svg'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'svg': svg,
};
}
}

View File

@@ -238,6 +238,7 @@ class _CitySelectionView extends StatelessWidget {
city.cityName,
city.isNetworkImage(),
selectedCityId,
city.cityIconPath,
);
},
);
@@ -260,12 +261,15 @@ class _CitySelectionView extends StatelessWidget {
String imageUrl,
String name,
bool isNetwork,
int selectedCityId, // Add this parameter
int selectedCityId,
String? svgIcon,
// Add this parameter
) {
final bool isSelected = cityId == selectedCityId; // Check if selected
return InkWell(
onTap: () async {
await LocalPreference.setSelectedCityId(cityId);
await LocalPreference.setSelectedCityLogo(svgIcon!);
Navigator.pop(context);
context.read<HomeBloc>().add(FetchHomeData());
debugPrint("Selected City ID: $cityId");

View File

@@ -1,3 +1,77 @@
// import 'package:bloc/bloc.dart';
// import 'package:citycards_customer/itinerary_creation/repository/itinerary_repository.dart';
// import 'package:citycards_customer/itinerary_creation/models/my_itinerary_model.dart';
// import 'package:citycards_customer/localPreference/local_preference.dart';
// import 'package:equatable/equatable.dart';
// part 'get_itinerary_event.dart';
// part 'get_itinerary_state.dart';
//
// class GetItineraryBloc extends Bloc<GetItineraryEvent, GetItineraryState> {
// final ItineraryRepository _repository;
//
// GetItineraryBloc({ItineraryRepository? repository})
// : _repository = repository ?? ItineraryRepository(),
// super(GetItineraryInitial()) {
// on<CheckLoginAndFetchItinerary>(_onCheckLoginAndFetch);
// on<GetIiterary>(_onGetItinerary);
// }
//
// Future<void> _onCheckLoginAndFetch(
// CheckLoginAndFetchItinerary event,
// Emitter<GetItineraryState> emit,
// ) async {
// try {
// emit(GetItineraryLoading());
//
// final isLoggedIn = await LocalPreference.getLogin();
//
// if (!isLoggedIn) {
// emit(GetItineraryNotLoggedIn());
// return;
// }
//
// final response = await _repository.fetchMyItineraries();
//
// // Check if user has unlimited pass
// if (!response.isUnlimitedPass) {
// emit(GetItineraryRequiresPass(itineraries: response.itineraries));
// return;
// }
//
// emit(GetItinerarySuccessfully(itineraries: response.itineraries));
// } catch (e) {
// emit(GetItineraryFailed(
// error: e.toString().contains('Exception')
// ? e.toString().replaceAll('Exception: ', '')
// : "Failed to load itineraries. Please try again."));
// }
// }
//
// Future<void> _onGetItinerary(
// GetIiterary event,
// Emitter<GetItineraryState> emit,
// ) async {
// try {
// emit(GetItineraryLoading());
//
// final response = await _repository.fetchMyItineraries();
//
// // Check if user has unlimited pass
// if (!response.isUnlimitedPass) {
// emit(GetItineraryRequiresPass(itineraries: response.itineraries));
// return;
// }
//
// emit(GetItinerarySuccessfully(itineraries: response.itineraries));
// } catch (e) {
// emit(GetItineraryFailed(
// error: e.toString().contains('Exception')
// ? e.toString().replaceAll('Exception: ', '')
// : "Failed to load itineraries. Please try again."));
// }
// }
// }
import 'package:bloc/bloc.dart';
import 'package:citycards_customer/itinerary_creation/repository/itinerary_repository.dart';
import 'package:citycards_customer/itinerary_creation/models/my_itinerary_model.dart';
@@ -23,19 +97,28 @@ class GetItineraryBloc extends Bloc<GetItineraryEvent, GetItineraryState> {
try {
emit(GetItineraryLoading());
// Check login status
final isLoggedIn = await LocalPreference.getLogin();
// Uncomment above and remove below line when ready for production
// final isLoggedIn = true; // For testing
if (!isLoggedIn) {
emit(GetItineraryNotLoggedIn());
return;
}
// If logged in, fetch itineraries
final response = await _repository.fetchMyItineraries();
emit(GetItinerarySuccessfully(itineraries: response.itineraries));
// Add static itinerary to the list
final itinerariesWithStatic = [
_createStaticItinerary(),
...response.itineraries,
];
// Check if user has unlimited pass
if (!response.isUnlimitedPass) {
emit(GetItineraryRequiresPass(itineraries: itinerariesWithStatic));
return;
}
emit(GetItinerarySuccessfully(itineraries: itinerariesWithStatic));
} catch (e) {
emit(GetItineraryFailed(
error: e.toString().contains('Exception')
@@ -53,7 +136,19 @@ class GetItineraryBloc extends Bloc<GetItineraryEvent, GetItineraryState> {
final response = await _repository.fetchMyItineraries();
emit(GetItinerarySuccessfully(itineraries: response.itineraries));
// Add static itinerary to the list
final itinerariesWithStatic = [
_createStaticItinerary(),
...response.itineraries,
];
// Check if user has unlimited pass
if (!response.isUnlimitedPass) {
emit(GetItineraryRequiresPass(itineraries: itinerariesWithStatic));
return;
}
emit(GetItinerarySuccessfully(itineraries: itinerariesWithStatic));
} catch (e) {
emit(GetItineraryFailed(
error: e.toString().contains('Exception')
@@ -61,4 +156,85 @@ class GetItineraryBloc extends Bloc<GetItineraryEvent, GetItineraryState> {
: "Failed to load itineraries. Please try again."));
}
}
// Helper method to create static/temporary itinerary
MyItinerary _createStaticItinerary() {
return MyItinerary(
id: -1, // Negative ID to identify as static data
userXid: 0,
cityXid: 1,
address: "Sample Location, City Center",
latitude: 40.7128,
longitude: -74.0060,
tripEnergy: "Relaxed",
travelingWithKids: false,
dietaryPreferences: ["Vegetarian"],
preferences: Preferences(
shopping: 3,
wildlife: 2,
landmarks: 5,
scenicViews: 4,
artAndMuseums: 5,
),
totalDays: 2,
aiModel: "static-v1",
promptVersion: "1.0",
isActive: true,
createdAt: DateTime.now().toIso8601String(),
updatedAt: DateTime.now().toIso8601String(),
days: [
ItineraryDay(
id: -1,
itineraryXid: -1,
dayNumber: 1,
title: "Day 1: City Exploration",
summary: "Explore the main attractions and local cuisine",
items: [
DayItem(
id: -1,
itineraryDayXid: -1,
timeSlot: "09:00 AM",
title: "Morning Coffee",
description: "Start your day with a cup of local coffee",
locationName: "Central Cafe",
imageUrl: "https://via.placeholder.com/300",
latitude: 40.7128,
longitude: -74.0060,
),
DayItem(
id: -2,
itineraryDayXid: -1,
timeSlot: "11:00 AM",
title: "Visit Historic Landmark",
description: "Explore the city's most famous landmark",
locationName: "City Monument",
imageUrl: "https://via.placeholder.com/300",
latitude: 40.7589,
longitude: -73.9851,
),
],
),
ItineraryDay(
id: -2,
itineraryXid: -1,
dayNumber: 2,
title: "Day 2: Museum & Parks",
summary: "Discover art and nature",
items: [
DayItem(
id: -3,
itineraryDayXid: -2,
timeSlot: "10:00 AM",
title: "Art Museum Visit",
description: "Immerse yourself in contemporary art",
locationName: "Modern Art Museum",
imageUrl: "https://via.placeholder.com/300",
latitude: 40.7614,
longitude: -73.9776,
),
],
),
],
);
}
}

View File

@@ -22,6 +22,15 @@ class GetItinerarySuccessfully extends GetItineraryState {
List<Object> get props => [itineraries];
}
class GetItineraryRequiresPass extends GetItineraryState {
final List<MyItinerary> itineraries;
const GetItineraryRequiresPass({required this.itineraries});
@override
List<Object> get props => [itineraries];
}
class GetItineraryFailed extends GetItineraryState {
final String error;

View File

@@ -9,6 +9,7 @@ import 'package:citycards_customer/postcard/widgets/dotted_border_container.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../common_bloc/bottom_navigation_bloc.dart';
import '../../login/view/login_email_bottomsheet.dart';
import 'package:intl/intl.dart';
@@ -30,7 +31,7 @@ class _MagicItineraryViewState extends State<MagicItineraryView> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFFFFF5F5),
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
@@ -40,10 +41,9 @@ class _MagicItineraryViewState extends State<MagicItineraryView> {
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: false,
showDivider: true,
),
SizedBox(height: 24.h),
// BLoC Builder for all states
BlocBuilder<GetItineraryBloc, GetItineraryState>(
builder: (context, state) {
@@ -56,6 +56,8 @@ class _MagicItineraryViewState extends State<MagicItineraryView> {
);
} else if (state is GetItineraryNotLoggedIn) {
return NotLoggedInItineraryView();
} else if (state is GetItineraryRequiresPass) {
return RequiresUnlimitedPassView();
} else if (state is GetItinerarySuccessfully) {
if (state.itineraries.isEmpty) {
return NoItineraryView();
@@ -192,8 +194,8 @@ class NotLoggedInItineraryView extends StatelessWidget {
}
}
class NoItineraryView extends StatelessWidget {
const NoItineraryView({super.key});
class RequiresUnlimitedPassView extends StatelessWidget {
const RequiresUnlimitedPassView({super.key});
@override
Widget build(BuildContext context) {
@@ -201,17 +203,17 @@ class NoItineraryView extends StatelessWidget {
children: [
SizedBox(height: 40.h),
// Illustration for no itineraries
Icon(
Icons.travel_explore,
size: 120.sp,
color: Colors.grey.withOpacity(0.3),
// Illustration image
Image.asset(
"assets/images/no_itinerary.png", // Update with your actual asset path
height: 300.h,
fit: BoxFit.contain,
),
SizedBox(height: 32.h),
CustomText(
text: "No Itineraries Yet",
text: "You do not possess an Unlimited Pass! 😔",
size: 18.sp,
weight: FontWeight.w600,
textAlign: TextAlign.center,
@@ -222,8 +224,7 @@ class NoItineraryView extends StatelessWidget {
Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: CustomText(
text:
"You haven't created any itineraries yet. Start planning your next adventure!",
text: "Get your Unlimited Pass and create a custom itinerary!",
size: 14.sp,
color: Color(0xFF656565),
textAlign: TextAlign.center,
@@ -232,6 +233,62 @@ class NoItineraryView extends StatelessWidget {
SizedBox(height: 32.h),
CustomFilledButton(
onTap: () {
context.read<NavigationBloc>().add(NavigationTabChanged(0));
},
label: "Buy Unlimited CityCard",
showArrow: true,
),
],
);
}
}
class NoItineraryView extends StatelessWidget {
const NoItineraryView({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Column(
children: [
SizedBox(height: 60.h),
/// Illustration Image
Center(
child: Image.asset(
"assets/images/no_itinerary.png",
height: 260.h,
fit: BoxFit.contain,
),
),
SizedBox(height: 32.h),
/// Title
CustomText(
text: "You Dont have an Itinerary Yet! 😟",
size: 18.sp,
weight: FontWeight.w600,
textAlign: TextAlign.center,
),
SizedBox(height: 12.h),
/// Subtitle
CustomText(
text:
"Create your own personalized magic itinerary that suites your travel needs",
size: 14.sp,
color: const Color(0xFF656565),
textAlign: TextAlign.center,
),
SizedBox(height: 32.h),
/// Button
CustomFilledButton(
onTap: () {
Navigator.push(
@@ -244,7 +301,10 @@ class NoItineraryView extends StatelessWidget {
label: "Create My Itinerary",
showArrow: true,
),
SizedBox(height: 40.h),
],
),
);
}
}

View File

@@ -22,14 +22,6 @@ class LocalDatabase {
path,
version: 1,
onCreate: (db, version) async {
/// CITY TABLE
await db.execute('''
CREATE TABLE selected_city (
id INTEGER PRIMARY KEY,
city_id INTEGER
)
''');
/// ONBOARDING TABLE
await db.execute('''
CREATE TABLE onboarding_state (
@@ -90,7 +82,8 @@ class LocalDatabase {
description TEXT
)
''');
/// CITY TABLE
/// CITY TABLE (with city_logo field)
await db.execute('''
CREATE TABLE selected_city (
id INTEGER PRIMARY KEY,

View File

@@ -52,6 +52,7 @@ class _LoginEmailBottomsheetState extends State<LoginEmailBottomsheet> {
),
);
} else if (state is LoginError) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage),

View File

@@ -1,6 +1,8 @@
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:citycards_customer/itinerary_creation/bloc/get_itinerary_bloc.dart';
import 'package:citycards_customer/my_pass/blocs/myPasses/my_passes_bloc.dart';
import 'package:citycards_customer/my_pass/blocs/myPasses/my_passes_event.dart';
import 'package:citycards_customer/postcard/blocs/myPostCards/my_postcard_bloc.dart';
import 'package:citycards_customer/profile/bloc/profile/profile_bloc.dart';
import 'package:citycards_customer/profile/bloc/profile/profile_event.dart';
@@ -48,6 +50,7 @@ class _VerifyOtpBottomsheetState extends State<VerifyOtpBottomsheet> {
context.read<MyPostCardBloc>().add(RefreshDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
// context.read<MyPostCardBloc>().add(FetchOrderPostCards());
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
// User exists - navigate to home/dashboard
// Navigator.of(context).pushReplacementNamed(RouteConstants.home);
ScaffoldMessenger.of(context).showSnackBar(
@@ -58,7 +61,7 @@ class _VerifyOtpBottomsheetState extends State<VerifyOtpBottomsheet> {
);
} else {
// User doesn't exist - navigate to create account
Navigator.of(context).pushReplacementNamed(RouteConstants.createAcct,arguments: widget.emailAddress);
Navigator.of(context).pushNamed(RouteConstants.createAcct,arguments: widget.emailAddress);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please complete your profile'),
@@ -74,6 +77,7 @@ class _VerifyOtpBottomsheetState extends State<VerifyOtpBottomsheet> {
),
);
} else if (state is VerifyOtpError) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage),

View File

@@ -1,4 +1,5 @@
import 'package:citycards_customer/cart/blocs/postcard_bloc.dart';
import 'package:citycards_customer/cart/repository/my_pass_cart_repository.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';
@@ -8,7 +9,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_stripe/flutter_stripe.dart'; // ADD THIS
import 'cart/blocs/myPassCart/my_pass_cart_bloc.dart';
import 'core/app_router.dart';
import 'core/global_keys.dart';
import 'home/bloc/FirstTimeUserHome/first_time_user_home_bloc.dart';
import 'home/bloc/FirstTimeUserHome/first_time_user_home_event.dart';
import 'home/bloc/registeredHome/home_bloc.dart';
@@ -18,7 +21,9 @@ import 'itinerary_creation/bloc/get_itinerary_bloc.dart';
import 'itinerary_creation/views/magic_itinerary_view.dart';
import 'login/bloc/login/login_bloc.dart';
import 'login/repository/login_repository.dart';
import 'my_pass/blocs/myPasses/my_passes_bloc.dart';
import 'my_pass/blocs/my_pass_bloc.dart';
import 'my_pass/repository/my_passes_repository.dart';
import 'postcard/blocs/myPostCards/my_postcard_bloc.dart';
import 'postcard/repository/my_postcard_repository.dart';
import 'profile/bloc/profile/profile_bloc.dart';
@@ -58,6 +63,12 @@ class MyApp extends StatelessWidget {
BlocProvider<MyPassBloc>(
create: (_) => MyPassBloc()..add(LoadMyPasses()),
),
BlocProvider<MyPassesBloc>(
create: (_) => MyPassesBloc(MyPassesRepository()),
),
BlocProvider<MyPassCartBloc>(
create: (_) => MyPassCartBloc(repository: MyPassCartRepository()),
),
BlocProvider<FirstTimeUserHomeBloc>(
create: (context) => FirstTimeUserHomeBloc(
FirstTimeUserHomeRepository(),
@@ -89,6 +100,7 @@ class MyApp extends StatelessWidget {
)
],
child: MaterialApp(
scaffoldMessengerKey: GlobalKeys.scaffoldMessengerKey,
onGenerateRoute: _appRouter.onGenerateRoute,
initialRoute: RouteConstants.splash,
debugShowCheckedModeBanner: false,

View File

@@ -0,0 +1,85 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../localPreference/local_preference.dart';
import '../../repository/my_passes_repository.dart';
import 'my_passes_event.dart';
import 'my_passes_state.dart';
class MyPassesBloc extends Bloc<MyPassesEvent, MyPassesState> {
final MyPassesRepository repository;
MyPassesBloc(this.repository) : super(MyPassesInitial()) {
on<CheckLoginAndFetchPasses>(_onCheckLoginAndFetchPasses);
on<FetchMyPasses>(_onFetchMyPasses);
on<RefreshMyPasses>(_onRefreshMyPasses);
}
Future<void> _onCheckLoginAndFetchPasses(
CheckLoginAndFetchPasses event,
Emitter<MyPassesState> emit,
) async {
try {
emit(MyPassesLoading());
// Check if user is logged in
final isLoggedIn = await LocalPreference.getLogin();
if (!isLoggedIn) {
emit(MyPassesNotLoggedIn());
return;
}
// User is logged in, fetch passes
final data = await repository.fetchMyPasses(
cardMode: event.cardMode,
sort: event.sort,
);
emit(MyPassesLoaded(data));
} catch (e) {
emit(MyPassesError(
e.toString().contains('Exception')
? e.toString().replaceAll('Exception: ', '')
: "Failed to load passes. Please try again."));
}
}
Future<void> _onFetchMyPasses(
FetchMyPasses event,
Emitter<MyPassesState> emit,
) async {
emit(MyPassesLoading());
try {
final data = await repository.fetchMyPasses(
cardMode: event.cardMode,
sort: event.sort,
);
emit(MyPassesLoaded(data));
} catch (e) {
emit(MyPassesError(
e.toString().contains('Exception')
? e.toString().replaceAll('Exception: ', '')
: "Failed to load passes. Please try again."));
}
}
Future<void> _onRefreshMyPasses(
RefreshMyPasses event,
Emitter<MyPassesState> emit,
) async {
try {
final data = await repository.fetchMyPasses(
cardMode: event.cardMode,
sort: event.sort,
);
emit(MyPassesLoaded(data));
} catch (e) {
emit(MyPassesError(
e.toString().contains('Exception')
? e.toString().replaceAll('Exception: ', '')
: "Failed to load passes. Please try again."));
}
}
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
abstract class MyPassesEvent extends Equatable {
const MyPassesEvent();
@override
List<Object?> get props => [];
}
/// Check Login and Fetch Passes Event
class CheckLoginAndFetchPasses extends MyPassesEvent {
final String cardMode;
final String sort;
const CheckLoginAndFetchPasses({
this.cardMode = "",
this.sort = "",
});
@override
List<Object?> get props => [cardMode, sort];
}
/// Initial / Normal Fetch
class FetchMyPasses extends MyPassesEvent {
final String cardMode;
final String sort;
const FetchMyPasses({
this.cardMode = "",
this.sort = "",
});
@override
List<Object?> get props => [cardMode, sort];
}
/// Refresh Event
class RefreshMyPasses extends MyPassesEvent {
final String cardMode;
final String sort;
const RefreshMyPasses({
this.cardMode = "",
this.sort = "",
});
@override
List<Object?> get props => [cardMode, sort];
}

View File

@@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import '../../models/my_passes_model.dart';
abstract class MyPassesState extends Equatable {
const MyPassesState();
@override
List<Object?> get props => [];
}
/// Initial State
class MyPassesInitial extends MyPassesState {}
/// Loading State
class MyPassesLoading extends MyPassesState {}
/// Not Logged In State
class MyPassesNotLoggedIn extends MyPassesState {}
/// Loaded State
class MyPassesLoaded extends MyPassesState {
final MyPassesModel passes;
const MyPassesLoaded(this.passes);
@override
List<Object?> get props => [passes];
}
/// Error State
class MyPassesError extends MyPassesState {
final String message;
const MyPassesError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../attractions/models/attraction_model.dart';
import '../../repository/my_passes_attractions_repository.dart';
import 'my_passes_attractions_event.dart';
import 'my_passes_attractions_state.dart';
class MyPassesAttractionsBloc
extends Bloc<MyPassesAttractionsEvent, MyPassesAttractionsState> {
final MyPassesAttractionsRepository repository;
MyPassesAttractionsBloc({required this.repository})
: super(MyPassesAttractionsInitial()) {
on<FetchMyPassesAttractionsByCategory>(_onFetchMyPassesAttractionsByCategory);
on<SearchMyPassesAttractions>(_onSearchMyPassesAttractions);
}
Future<void> _onFetchMyPassesAttractionsByCategory(
FetchMyPassesAttractionsByCategory event,
Emitter<MyPassesAttractionsState> emit,
) async {
emit(MyPassesAttractionsLoading());
try {
final AttractionsResponse response =
await repository.fetchMyPassesAttractions(
cityXid: event.cityXid,
categoryXid: event.categoryXid, // Can be null
);
final attractions = response.attractions ?? [];
emit(
MyPassesAttractionsLoaded(
attractions: attractions,
filteredAttractions: attractions, // Initially show all
categories: response.categories ?? [],
selectedCategoryId: event.categoryXid, // Can be null
searchQuery: '', // Reset search query on category change
),
);
} catch (e) {
emit(
MyPassesAttractionsError(
e.toString(),
),
);
}
}
void _onSearchMyPassesAttractions(
SearchMyPassesAttractions event,
Emitter<MyPassesAttractionsState> emit,
) {
final currentState = state;
if (currentState is MyPassesAttractionsLoaded) {
final query = event.query.toLowerCase();
final filtered = currentState.attractions.where((attraction) {
if (query.isEmpty) return true;
return attraction.title?.toLowerCase().contains(query) ?? false;
}).toList();
emit(
currentState.copyWith(
filteredAttractions: filtered,
searchQuery: event.query,
),
);
}
}
}

View File

@@ -0,0 +1,30 @@
import 'package:equatable/equatable.dart';
abstract class MyPassesAttractionsEvent extends Equatable {
const MyPassesAttractionsEvent();
@override
List<Object?> get props => [];
}
class FetchMyPassesAttractionsByCategory extends MyPassesAttractionsEvent {
final int cityXid;
final int? categoryXid;
const FetchMyPassesAttractionsByCategory({
required this.cityXid,
this.categoryXid,
});
@override
List<Object?> get props => [cityXid, categoryXid];
}
class SearchMyPassesAttractions extends MyPassesAttractionsEvent {
final String query;
const SearchMyPassesAttractions(this.query);
@override
List<Object?> get props => [query];
}

View File

@@ -0,0 +1,64 @@
import 'package:equatable/equatable.dart';
import '../../../attractions/models/attraction_model.dart';
abstract class MyPassesAttractionsState extends Equatable {
const MyPassesAttractionsState();
@override
List<Object?> get props => [];
}
class MyPassesAttractionsInitial extends MyPassesAttractionsState {}
class MyPassesAttractionsLoading extends MyPassesAttractionsState {}
class MyPassesAttractionsLoaded extends MyPassesAttractionsState {
final List<Attraction> attractions;
final List<Attraction> filteredAttractions;
final List<Category> categories;
final int? selectedCategoryId;
final String searchQuery;
const MyPassesAttractionsLoaded({
required this.attractions,
required this.filteredAttractions,
required this.categories,
this.selectedCategoryId,
this.searchQuery = '',
});
MyPassesAttractionsLoaded copyWith({
List<Attraction>? attractions,
List<Attraction>? filteredAttractions,
List<Category>? categories,
int? selectedCategoryId,
String? searchQuery,
}) {
return MyPassesAttractionsLoaded(
attractions: attractions ?? this.attractions,
filteredAttractions: filteredAttractions ?? this.filteredAttractions,
categories: categories ?? this.categories,
selectedCategoryId: selectedCategoryId ?? this.selectedCategoryId,
searchQuery: searchQuery ?? this.searchQuery,
);
}
@override
List<Object?> get props => [
attractions,
filteredAttractions,
categories,
selectedCategoryId,
searchQuery,
];
}
class MyPassesAttractionsError extends MyPassesAttractionsState {
final String message;
const MyPassesAttractionsError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/my_passes_details_repository.dart';
import 'my_passes_details_event.dart';
import 'my_passes_details_state.dart';
class MyPassesDetailsBloc
extends Bloc<MyPassesDetailsEvent, MyPassesDetailsState> {
final MyPassesDetailsRepository repository;
MyPassesDetailsBloc({required this.repository})
: super(MyPassesDetailsInitial()) {
on<FetchMyPassDetails>(_fetchPassDetails);
}
Future<void> _fetchPassDetails(
FetchMyPassDetails event,
Emitter<MyPassesDetailsState> emit,
) async {
emit(MyPassesDetailsLoading());
try {
final response =
await repository.fetchPassDetails(passId: event.passId);
emit(MyPassesDetailsLoaded(data: response));
} catch (e) {
emit(MyPassesDetailsError(message: e.toString()));
}
}
}

View File

@@ -0,0 +1,17 @@
import 'package:equatable/equatable.dart';
abstract class MyPassesDetailsEvent extends Equatable {
const MyPassesDetailsEvent();
@override
List<Object?> get props => [];
}
class FetchMyPassDetails extends MyPassesDetailsEvent {
final int passId;
const FetchMyPassDetails({required this.passId});
@override
List<Object?> get props => [passId];
}

View File

@@ -0,0 +1,32 @@
import 'package:equatable/equatable.dart';
import '../../models/my_passes_details_model.dart';
abstract class MyPassesDetailsState extends Equatable {
const MyPassesDetailsState();
@override
List<Object?> get props => [];
}
class MyPassesDetailsInitial extends MyPassesDetailsState {}
class MyPassesDetailsLoading extends MyPassesDetailsState {}
class MyPassesDetailsLoaded extends MyPassesDetailsState {
final MyPassesDetailsModel data;
const MyPassesDetailsLoaded({required this.data});
@override
List<Object?> get props => [data];
}
class MyPassesDetailsError extends MyPassesDetailsState {
final String message;
const MyPassesDetailsError({required this.message});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../search_offers/model/offers_model.dart';
import '../../repository/my_passes_offers_repository.dart';
import 'my_passes_offers_event.dart';
import 'my_passes_offers_state.dart';
class MyPassesOffersBloc extends Bloc<MyPassesOffersEvent, MyPassesOffersState> {
final MyPassesOffersRepository repository;
List<Offer> _allOffers = [];
MyPassesOffersBloc(this.repository) : super(MyPassesOffersInitial()) {
on<LoadMyPassesOffers>(_onLoadMyPassesOffers);
on<SearchMyPassesOffers>(_onSearchMyPassesOffers);
}
Future<void> _onLoadMyPassesOffers(
LoadMyPassesOffers event,
Emitter<MyPassesOffersState> emit,
) async {
emit(MyPassesOffersLoading());
try {
final response = await repository.fetchMyPassesOffers(
cityXid: event.cityXid,
categoryXid: event.categoryXid,
);
_allOffers = response.offers;
emit(
MyPassesOffersLoaded(
offers: response.offers,
categories: response.categories,
),
);
} catch (e) {
emit(MyPassesOffersError(e.toString()));
}
}
void _onSearchMyPassesOffers(
SearchMyPassesOffers event,
Emitter<MyPassesOffersState> emit,
) {
final filtered = _allOffers
.where(
(offer) =>
offer.title
.toLowerCase()
.contains(event.query.toLowerCase()) ||
offer.description
.toLowerCase()
.contains(event.query.toLowerCase()),
)
.toList();
if (state is MyPassesOffersLoaded) {
emit(
MyPassesOffersLoaded(
offers: filtered,
categories: (state as MyPassesOffersLoaded).categories,
),
);
}
}
}

View File

@@ -0,0 +1,16 @@
abstract class MyPassesOffersEvent {}
class LoadMyPassesOffers extends MyPassesOffersEvent {
final int cityXid;
final int? categoryXid;
LoadMyPassesOffers({
required this.cityXid,
this.categoryXid,
});
}
class SearchMyPassesOffers extends MyPassesOffersEvent {
final String query;
SearchMyPassesOffers(this.query);
}

View File

@@ -0,0 +1,22 @@
import '../../../search_offers/model/offers_model.dart';
abstract class MyPassesOffersState {}
class MyPassesOffersInitial extends MyPassesOffersState {}
class MyPassesOffersLoading extends MyPassesOffersState {}
class MyPassesOffersLoaded extends MyPassesOffersState {
final List<Offer> offers;
final List<Category> categories;
MyPassesOffersLoaded({
required this.offers,
required this.categories,
});
}
class MyPassesOffersError extends MyPassesOffersState {
final String message;
MyPassesOffersError(this.message);
}

View File

@@ -0,0 +1,167 @@
class MyPassesDetailsModel {
final City? city;
final List<Attraction> attractions;
final List<Offer> offers;
MyPassesDetailsModel({
this.city,
required this.attractions,
required this.offers,
});
factory MyPassesDetailsModel.fromJson(Map<String, dynamic>? json) {
return MyPassesDetailsModel(
city: json?['city'] != null
? City.fromJson(json?['city'])
: null,
attractions: (json?['attractions'] as List<dynamic>?)
?.map((e) => Attraction.fromJson(e))
.toList() ??
[],
offers: (json?['offers'] as List<dynamic>?)
?.map((e) => Offer.fromJson(e))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'city': city?.toJson(),
'attractions': attractions.map((e) => e.toJson()).toList(),
'offers': offers.map((e) => e.toJson()).toList(),
};
}
}
class City {
final num id;
final String name;
final String cardMode;
final String validUpto;
final num totalAdult;
final num totalChild;
final num noOfDays;
final num noOfAttractions;
City({
required this.id,
required this.name,
required this.cardMode,
required this.validUpto,
required this.totalAdult,
required this.totalChild,
required this.noOfDays,
required this.noOfAttractions,
});
factory City.fromJson(Map<String, dynamic>? json) {
return City(
id: json?['id'] ?? 0,
name: json?['name'] ?? '',
cardMode: json?['cardMode'] ?? '',
validUpto: json?['validUpto'] ?? '',
totalAdult: json?['totalAdult'] ?? 0,
totalChild: json?['totalChild'] ?? 0,
noOfDays: json?['noOfDays'] ?? 0,
noOfAttractions: json?['noOfAttractions'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'cardMode': cardMode,
'validUpto': validUpto,
'totalAdult': totalAdult,
'totalChild': totalChild,
'noOfDays': noOfDays,
'noOfAttractions': noOfAttractions,
};
}
}
class Attraction {
final num id;
final String title;
final String description;
final num? ticketPriceAdult;
final num? ticketPriceChild;
final String? bookingEmail;
final String? bookingPhoneNumber;
final String image;
Attraction({
required this.id,
required this.title,
required this.description,
this.ticketPriceAdult,
this.ticketPriceChild,
this.bookingEmail,
this.bookingPhoneNumber,
required this.image,
});
factory Attraction.fromJson(Map<String, dynamic>? json) {
return Attraction(
id: json?['id'] ?? 0,
title: json?['title'] ?? '',
description: json?['description'] ?? '',
ticketPriceAdult: json?['ticketPriceAdult'],
ticketPriceChild: json?['ticketPriceChild'],
bookingEmail: json?['bookingEmail'],
bookingPhoneNumber: json?['bookingPhoneNumber'],
image: json?['image'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'ticketPriceAdult': ticketPriceAdult,
'ticketPriceChild': ticketPriceChild,
'bookingEmail': bookingEmail,
'bookingPhoneNumber': bookingPhoneNumber,
'image': image,
};
}
}
class Offer {
final num id;
final String title;
final String description;
final String mobileBannerImage;
final String websiteBannerImage;
Offer({
required this.id,
required this.title,
required this.description,
required this.mobileBannerImage,
required this.websiteBannerImage,
});
factory Offer.fromJson(Map<String, dynamic>? json) {
return Offer(
id: json?['id'] ?? 0,
title: json?['title'] ?? '',
description: json?['description'] ?? '',
mobileBannerImage: json?['mobileBannerImage'] ?? '',
websiteBannerImage: json?['websiteBannerImage'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'mobileBannerImage': mobileBannerImage,
'websiteBannerImage': websiteBannerImage,
};
}
}

View File

@@ -0,0 +1,119 @@
class MyPassesModel {
final List<MyPassData>? data;
MyPassesModel({
this.data,
});
factory MyPassesModel.fromJson(List<dynamic>? json) {
return MyPassesModel(
data: json != null
? json.map((e) => MyPassData.fromJson(e)).toList()
: [],
);
}
List<dynamic> toJson() {
return data != null
? data!.map((e) => e.toJson()).toList()
: [];
}
}
class MyPassData {
final num? id;
final String? bookingNumber;
final String? cardMode;
final String? validUpto;
final num? totalAdult;
final num? totalChild;
final num? totalAmount;
final String? bookingStatus;
final num? noOfAttractions;
final num? noOfDays;
final String? paymentStatus;
final String? updatedAt;
final City? city;
MyPassData({
this.id,
this.bookingNumber,
this.cardMode,
this.validUpto,
this.totalAdult,
this.totalChild,
this.totalAmount,
this.bookingStatus,
this.noOfAttractions,
this.noOfDays,
this.paymentStatus,
this.updatedAt,
this.city,
});
factory MyPassData.fromJson(Map<String, dynamic>? json) {
return MyPassData(
id: json?['id'] ?? 0,
bookingNumber: json?['bookingNumber'] ?? '',
cardMode: json?['cardMode'] ?? '',
validUpto: json?['validUpto'] ?? '',
totalAdult: json?['totalAdult'] ?? 0,
totalChild: json?['totalChild'] ?? 0,
totalAmount: json?['totalAmount'] ?? 0,
bookingStatus: json?['bookingStatus'] ?? '',
noOfAttractions: json?['noOfAttractions'] ?? 0,
noOfDays: json?['noOfDays'] ?? 0,
paymentStatus: json?['paymentStatus'] ?? '',
updatedAt: json?['updatedAt'] ?? '',
city: json?['city'] != null
? City.fromJson(json?['city'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id ?? 0,
'bookingNumber': bookingNumber ?? '',
'cardMode': cardMode ?? '',
'validUpto': validUpto ?? '',
'totalAdult': totalAdult ?? 0,
'totalChild': totalChild ?? 0,
'totalAmount': totalAmount ?? 0,
'bookingStatus': bookingStatus ?? '',
'noOfAttractions': noOfAttractions ?? 0,
'noOfDays': noOfDays ?? 0,
'paymentStatus': paymentStatus ?? '',
'updatedAt': updatedAt ?? '',
'city': city?.toJson(),
};
}
}
class City {
final num? id;
final String? name;
final String? bannerImage;
City({
this.id,
this.name,
this.bannerImage,
});
factory City.fromJson(Map<String, dynamic>? json) {
return City(
id: json?['id'] ?? 0,
name: json?['name'] ?? '',
bannerImage: json?['bannerImage'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id ?? 0,
'name': name ?? '',
'bannerImage': bannerImage ?? '',
};
}
}

View File

@@ -0,0 +1,29 @@
import 'package:citycards_customer/networkApiServices/api_urls.dart';
import '../../attractions/models/attraction_model.dart';
import '../../networkApiServices/network_api_services.dart';
class MyPassesAttractionsRepository {
final NetworkApiService _apiServices = NetworkApiService();
/// Fetch my passes attractions by cityXid and optional categoryXid
Future<AttractionsResponse> fetchMyPassesAttractions({
required int cityXid,
int? categoryXid,
}) async {
try {
// Base URL
String url = '${ApiUrls.passAttractionsList}?cityXid=$cityXid';
// Add categoryXid if provided
if (categoryXid != null) {
url = '$url&categoryXid=$categoryXid';
}
final response = await _apiServices.getApi(url: url);
return AttractionsResponse.fromJson(response.data);
} catch (e) {
throw Exception('Failed to fetch my passes attractions: $e');
}
}
}

View File

@@ -0,0 +1,18 @@
import '../models/my_passes_details_model.dart';
import '../../networkApiServices/network_api_services.dart';
import '../../networkApiServices/api_urls.dart';
class MyPassesDetailsRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Fetch pass details by passId
Future<MyPassesDetailsModel> fetchPassDetails({
required int passId,
}) async {
final response = await _apiService.getApi(
url: '${ApiUrls.passDetails}/$passId/details',
);
return MyPassesDetailsModel.fromJson(response.data);
}
}

View File

@@ -0,0 +1,25 @@
import '../../networkApiServices/api_urls.dart';
import '../../search_offers/model/offers_model.dart';
import '../../networkApiServices/network_api_services.dart';
class MyPassesOffersRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Fetch my passes offers by cityXid and optionally by categoryXid
Future<OffersResponse> fetchMyPassesOffers({
required int cityXid,
int? categoryXid,
}) async {
String url = '${ApiUrls.passOffers}?cityXid=$cityXid';
if (categoryXid != null) {
url += '&categoryXid=$categoryXid';
}
final response = await _apiService.getApi(
url: url,
);
return OffersResponse.fromJson(response.data);
}
}

View File

@@ -0,0 +1,32 @@
import '../models/my_passes_model.dart';
import '../../networkApiServices/network_api_services.dart';
import '../../networkApiServices/api_urls.dart';
class MyPassesRepository {
final NetworkApiService _apiService = NetworkApiService();
Future<MyPassesModel> fetchMyPasses({
String cardMode = "",
String sort = "",
}) async {
String url = ApiUrls.myPasses;
List<String> queryParams = [];
if (cardMode.isNotEmpty) {
queryParams.add("cardMode=$cardMode");
}
if (sort.isNotEmpty) {
queryParams.add("sort=$sort");
}
if (queryParams.isNotEmpty) {
url += "?${queryParams.join("&")}";
}
final response = await _apiService.getApi(url: url);
return MyPassesModel.fromJson(response.data);
}
}

View File

@@ -3,99 +3,196 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../common_bloc/bottom_navigation_bloc.dart';
import '../../common_packages/custom_filled_button.dart';
import '../../core/route_constants.dart';
import '../blocs/my_pass_bloc.dart';
import '../../login/view/login_email_bottomsheet.dart';
import '../blocs/myPasses/my_passes_bloc.dart';
import '../blocs/myPasses/my_passes_event.dart';
import '../blocs/myPasses/my_passes_state.dart';
import '../widgets/pass_widget.dart';
class MyPassesView extends StatelessWidget {
class MyPassesView extends StatefulWidget {
const MyPassesView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<MyPassBloc, MyPassState>(
builder: (context, state) {
if (state is MyPassLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is MyPassEmpty) {
return _noPassView(context);
} else if (state is MyPassLoaded) {
return _passListView(state.passes);
}
return const SizedBox.shrink();
},
);
State<MyPassesView> createState() => _MyPassesViewState();
}
Widget _noPassView(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 30.h),
class _MyPassesViewState extends State<MyPassesView> {
String selectedCardMode = "";
String selectedSort = "";
@override
void initState() {
super.initState();
// Changed from FetchMyPasses to CheckLoginAndFetchPasses
context.read<MyPassesBloc>().add(const CheckLoginAndFetchPasses());
}
void _showCardModeBottomSheet() {
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
builder: (context) {
return Container(
padding: EdgeInsets.all(16.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/no_pass.png', // your woman sitting image
height: 180.h,
ListTile(
title: Text(
"All",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
SizedBox(height: 20.h),
Text(
"You Dont have a Pass Yet! 😕",
style: GoogleFonts.poppins(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
Text(
"Get a pass and get offers and discounts and\nmore on your trip to your favourite city",
style: GoogleFonts.poppins(fontSize: 12.sp, color: Colors.black54),
textAlign: TextAlign.center,
),
SizedBox(height: 24.h),
GestureDetector(
onTap: () {
// Navigate to Buy a Pass
Navigator.pushNamed(context, '/buyPass');
setState(() {
selectedCardMode = "";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: "",
sort: selectedSort,
));
},
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 14.h),
decoration: BoxDecoration(
color: const Color(0xffFF5A5F),
borderRadius: BorderRadius.circular(30.r),
),
child: Center(
child: Text(
"Buy a Pass",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
ListTile(
title: Text(
"flexi",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedCardMode = "flexi";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: "flexi",
sort: selectedSort,
));
},
),
ListTile(
title: Text(
"unlimited",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedCardMode = "unlimited";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: "unlimited",
sort: selectedSort,
));
},
),
],
),
);
},
);
}
Widget _passListView(List passes) {
void _showSortBottomSheet() {
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
builder: (context) {
return Container(
padding: EdgeInsets.all(16.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(
"All",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedSort = "";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: selectedCardMode,
sort: "",
));
},
),
ListTile(
title: Text(
"latest",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedSort = "latest";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: selectedCardMode,
sort: "latest",
));
},
),
ListTile(
title: Text(
"oldest",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedSort = "oldest";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: selectedCardMode,
sort: "oldest",
));
},
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: BlocBuilder<MyPassesBloc, MyPassesState>(
builder: (context, state) {
if (state is MyPassesLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is MyPassesNotLoggedIn) {
// New state handling for not logged in users
return _notLoggedInView(context);
} else if (state is MyPassesLoaded) {
return SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,),
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
SizedBox(height: 10.h),
Row(
children: [
Container(
GestureDetector(
onTap: _showSortBottomSheet,
child: Container(
width: 130.w,
height: 36.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
@@ -107,7 +204,7 @@ class MyPassesView extends StatelessWidget {
child: Row(
children: [
Text(
"Sort by Date",
selectedSort.isEmpty ? "Sort by Date" : selectedSort,
style: GoogleFonts.poppins(fontSize: 12.sp),
),
const Spacer(),
@@ -115,8 +212,11 @@ class MyPassesView extends StatelessWidget {
],
),
),
),
SizedBox(width: 10.w),
Container(
GestureDetector(
onTap: _showCardModeBottomSheet,
child: Container(
height: 36.h,
width: 130.w,
padding: EdgeInsets.symmetric(horizontal: 12.w),
@@ -128,7 +228,7 @@ class MyPassesView extends StatelessWidget {
child: Row(
children: [
Text(
"All",
selectedCardMode.isEmpty ? "All" : selectedCardMode,
style: GoogleFonts.poppins(fontSize: 12.sp),
),
const Spacer(),
@@ -136,10 +236,171 @@ class MyPassesView extends StatelessWidget {
],
),
),
),
],
),
SizedBox(height: 20.h),
ListView.builder(
if (state.passes.data == null || state.passes.data!.isEmpty)
_noPassView(context)
else
_passListView(state.passes.data!),
],
),
);
} else if (state is MyPassesError) {
return Center(
child: Text(
state.message,
style: GoogleFonts.poppins(fontSize: 14.sp, color: Colors.red),
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
/// New widget for not logged in state
Widget _notLoggedInView(BuildContext context) {
return SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
SizedBox(height: 40.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w),
child: Column(
children: [
/// Illustration Image
Center(
child: Image.asset(
"assets/images/no_itinerary.png", // You can use a different image if available
height: 260.h,
fit: BoxFit.contain,
),
),
SizedBox(height: 32.h),
/// Title
Text(
"Please Log In to View Your Passes",
style: GoogleFonts.poppins(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
textAlign: TextAlign.center,
),
SizedBox(height: 12.h),
/// Subtitle
Text(
"Log in to access your passes, exclusive offers, and discounts on your trip to your favourite city.",
style: GoogleFonts.poppins(
fontSize: 14.sp,
color: const Color(0xFF656565),
),
textAlign: TextAlign.center,
),
SizedBox(height: 32.h),
/// Login Button
CustomFilledButton(
onTap: () {
showModalBottomSheet(
backgroundColor: Colors.white,
context: context,
isScrollControlled: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)),
),
builder: (_) => const LoginEmailBottomsheet(),
);
},
label: "Log In",
showArrow: true,
),
SizedBox(height: 40.h),
],
),
),
],
),
);
}
Widget _noPassView(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Column(
children: [
SizedBox(height: 60.h),
/// Illustration Image
Center(
child: Image.asset(
"assets/images/no_itinerary.png",
height: 260.h,
fit: BoxFit.contain,
),
),
SizedBox(height: 32.h),
/// Title
Text(
"You Don't have a Pass Yet! 😕",
style: GoogleFonts.poppins(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
textAlign: TextAlign.center,
),
SizedBox(height: 12.h),
/// Subtitle
Text(
"Get a pass and unlock exclusive offers, discounts, and more on your trip to your favourite city.",
style: GoogleFonts.poppins(
fontSize: 14.sp,
color: const Color(0xFF656565),
),
textAlign: TextAlign.center,
),
SizedBox(height: 32.h),
/// Custom Filled Button
CustomFilledButton(
onTap: () {
context.read<NavigationBloc>().add(NavigationTabChanged(0));
},
label: "Buy a Pass",
showArrow: true,
),
SizedBox(height: 40.h),
],
),
);
}
Widget _passListView(List passes) {
return ListView.builder(
itemCount: passes.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -149,20 +410,15 @@ class MyPassesView extends StatelessWidget {
padding: EdgeInsets.only(bottom: 16.h),
child: InkWell(
onTap: () {
context.read<MyPassBloc>().add(SelectPass(pass));
Navigator.of(
context,
).pushNamed(RouteConstants.qrPage);
Navigator.of(context).pushNamed(
RouteConstants.qrPage,
arguments: pass.id, // Pass your booking ID here
);
},
child: PassTicketCard(pass: pass),
),
);
},
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,727 @@
import 'package:citycards_customer/attraction_details/widgets/share_bottomsheet.dart';
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:latlong2/latlong.dart';
import '../../attraction_details/bloc/attraction_details_bloc.dart';
import '../../attraction_details/bloc/attraction_details_event.dart';
import '../../attraction_details/bloc/attraction_details_state.dart';
import '../../attraction_details/repository/attraction_details_repository.dart';
import '../../core/route_constants.dart';
class PassAttractionDetailsView extends StatelessWidget {
final int? attractionId;
const PassAttractionDetailsView({
super.key,
required this.attractionId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AttractionDetailsBloc(
repository: AttractionDetailsRepository(),
)..add(FetchAttractionDetails(attractionId: attractionId??0)),
child: BlocBuilder<AttractionDetailsBloc, AttractionDetailsState>(
builder: (context, state) {
if (state is AttractionDetailsLoading) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (state is AttractionDetailsError) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text(
state.message,
style: TextStyle(color: Colors.red),
),
),
);
}
if (state is AttractionDetailsLoaded) {
final attraction = state.attractionDetails;
final coverImage = attraction.attractionGalleries
.firstWhere(
(gallery) => gallery.isCoverImage,
orElse: () => attraction.attractionGalleries.first,
)
.filePathUrl;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Image.network(
coverImage,
height: 377.h,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return 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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: true,
isProfilePage: false,
showDivider: true,
),
SizedBox(height: 10.h),
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(
Icons.arrow_back,
size: 24.sp,
color: Colors.white,
),
),
SizedBox(width: 8.w),
Expanded(
child: Text(
attraction.title,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
),
Positioned(
bottom: 31.h,
left: 12.w,
right: 60.w, // Add this - leaves space for share button
child: Text(
attraction.title,
style: TextStyle(
color: Colors.white,
fontSize: 44.sp,
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
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: 16.w, vertical: 24.h),
child: Container(
width: double.infinity,
padding: EdgeInsets.all(20.w),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(16.r),
border: Border.all(
color: Color(0xFFFDCDCE),
width: 1.5,
),
),
child: Column(
children: [
Text(
"Scan this at the site of the attraction",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: Color(0xFFF95F62),
),
textAlign: TextAlign.center,
),
SizedBox(height: 20.h),
// QR Code Image
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
),
child: Image.asset(
'assets/images/qr_image.png',
height: 200.h,
width: 200.w,
fit: BoxFit.contain,
),
),
SizedBox(height: 16.h),
// QR Code Text
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"IYFHHVN254ADSD",
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
letterSpacing: 1.2,
),
),
SizedBox(width: 8.w),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: "IYFHHVN254ADSD"));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Code copied to clipboard'),
duration: Duration(seconds: 2),
backgroundColor: Color(0xFFF95F62),
),
);
},
child: Icon(
Icons.copy,
size: 18.sp,
color: Colors.black54,
),
),
],
),
SizedBox(height: 20.h),
// Check in Button
SizedBox(
width: double.infinity,
height: 50.h,
child: ElevatedButton(
onPressed: () {
// Add your check-in logic here
},
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFFF95F62),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.r),
),
elevation: 0,
),
child: Text(
"Check in",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
SizedBox(height: 12.h),
// Help Text
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Having problems redeeming the pass? ",
style: TextStyle(
fontSize: 12.sp,
color: Colors.black54,
),
),
GestureDetector(
onTap: () {
// Add your help/support navigation here
},
child: Text(
"Click Here",
style: TextStyle(
fontSize: 12.sp,
color: Color(0xFFF95F62),
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
),
],
),
],
),
),
),
// About Section
Padding(
padding:
EdgeInsets.only(left: 16.w, right: 16.w,),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"About",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.32.h),
Text(
attraction.description,
style: TextStyle(
color: Color(0xFF262626),
fontWeight: FontWeight.w400,
fontSize: 14.sp,
height: 1.5,
),
),
],
),
),
SizedBox(height: 41.h),
// Booking Section
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"How to make a booking?",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Color(0xFFF95F62)),
),
child: Row(
children: [
Icon(
Icons.call,
color: Color(0xFFF95F62),
size: 32.w,
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
CustomText(
text: "Contact Number",
color: Colors.black.withOpacity(.6),
size: 12.sp,
weight: FontWeight.w500,
),
SizedBox(height: 6.h),
CustomText(
text: attraction.bookingPhoneNumber??"N/A",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
),
SizedBox(height: 6.h),
CustomText(
text: "Tap to call",
color: Colors.black.withOpacity(.4),
size: 12.sp,
weight: FontWeight.w400,
),
],
),
),
],
),
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Color(0xFFF95F62)),
),
child: Row(
children: [
Icon(
Icons.email_sharp,
color: Color(0xFFF95F62),
size: 32.w,
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
CustomText(
text: "Email",
color: Colors.black.withOpacity(.6),
size: 12.sp,
weight: FontWeight.w500,
),
SizedBox(height: 6.h),
CustomText(
text: attraction.bookingEmail??"N/A",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
),
SizedBox(height: 6.h),
CustomText(
text: "Tap to email",
color: Colors.black.withOpacity(.4),
size: 12.sp,
weight: FontWeight.w400,
),
],
),
),
],
),
),
SizedBox(height: 16.h),
InkWell(
onTap: () {
Navigator.of(context)
.pushNamed(RouteConstants.makeBooking);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 24.w,
vertical: 18.h,
),
decoration: BoxDecoration(
color: Color(0xFFF95F62),
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
CustomText(
text: "Via CityCards",
size: 16.sp,
weight: FontWeight.w500,
color: Colors.white,
),
SizedBox(height: 8.h),
CustomText(
text: "Create a booking via app",
size: 11.sp,
weight: FontWeight.w400,
color: Colors.white,
),
],
),
),
Icon(
Icons.arrow_forward_ios_outlined,
color: Colors.white,
),
],
),
),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"What is included",
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4.h),
// Dynamic Inclusions from API
Wrap(
runSpacing: 16.h,
spacing: 16.w,
children: attraction.attractionInclusions
.where((inclusion) => inclusion.isInclusion)
.map(
(inclusion) => includedBox(
"assets/icons/bus.png",
inclusion.title,
inclusion.description,
),
)
.toList(),
),
SizedBox(height: 30.h),
// Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"Exact Location",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 8.h),
CustomText(
text: "View the location on map",
size: 12.sp,
color: Colors.black.withOpacity(.6),
),
SizedBox(height: 17.h),
Container(
height: 178.7.h,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(13.54.r),
border: Border.all(
color: Colors.grey.withOpacity(0.3),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13.54.r),
child: FlutterMap(
options: MapOptions(
initialCenter: LatLng(
attraction.latitudeCoordinate,
attraction.longitudeCoordinate,
),
initialZoom: 15.0,
interactionOptions: InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.citycards_customer',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(
attraction.latitudeCoordinate,
attraction.longitudeCoordinate,
),
width: 40.w,
height: 40.h,
child: Icon(
Icons.location_on,
color: Color(0xFFF95F62),
size: 40.sp,
),
),
],
),
],
),
),
),
SizedBox(height: 17.h),
CustomText(
text: attraction.address,
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"People frequently ask",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 15.h),
Column(
children: attraction.attractionFaqs.map((faq) {
return Padding(
padding: EdgeInsets.only(bottom: 15.h),
child: faqBox(
title: faq.faqQuestion,
desc: faq.faqAnswer,
),
);
}).toList(),
),
],
),
),
SizedBox(height: 24.h),
],
),
),
),
);
}
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text("Something went wrong"),
),
);
},
),
);
}
Widget includedBox(String icon, String title, String disc) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(10.r),
border: Border.all(color: Color(0xFFFDCDCE)),
),
child: Row(
children: [
Image.asset(icon, scale: 4),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: Color(0xFF212121),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4.h),
CustomText(
text: disc,
size: 11.sp,
weight: FontWeight.w400,
color: Color(0xFF666666),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget faqBox({
required String title,
required String desc,
}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
border: Border.all(color: const Color(0xFFFDCDCE)),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: const Color(0xFF212121),
),
),
SizedBox(width: 20.w),
Icon(
Icons.arrow_forward_ios_outlined,
size: 18.sp,
color: Colors.black,
),
],
),
SizedBox(height: 9.h),
CustomText(
text: desc,
size: 11.sp,
color: const Color(0xFF7D7D7D),
),
],
),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/back_widget.dart';
import 'package:citycards_customer/my_pass/widgets/pass_attraction_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../attractions/widget/filter_chip.dart';
import '../../common_packages/custom_search_field.dart';
import '../blocs/myPassesAttrctions/my_passes_attractions_bloc.dart';
import '../blocs/myPassesAttrctions/my_passes_attractions_event.dart';
import '../blocs/myPassesAttrctions/my_passes_attractions_state.dart';
import '../repository/my_passes_attractions_repository.dart';
class PassAttractionsPage extends StatelessWidget {
final int cityXid;
final String source;
const PassAttractionsPage({
super.key,
required this.cityXid,
required this.source,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
final bloc = MyPassesAttractionsBloc(
repository: MyPassesAttractionsRepository(),
);
// Fetch attractions with cityXid
bloc.add(
FetchMyPassesAttractionsByCategory(
cityXid: cityXid,
),
);
return bloc;
},
child: BlocBuilder<MyPassesAttractionsBloc, MyPassesAttractionsState>(
builder: (context, state) {
final bloc = context.read<MyPassesAttractionsBloc>();
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App bar
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
backWidget(context, "Pass Attractions", Colors.black),
const SizedBox(height: 20),
// 🔍 Search field with BLoC logic
CommonSearchField(
hint: "Search attractions...",
hintColor: Colors.grey.shade500,
onChanged: (value) {
bloc.add(SearchMyPassesAttractions(value));
},
),
const SizedBox(height: 16),
// 🖼️ Category chips row - DYNAMIC
if (state is MyPassesAttractionsLoaded)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: state.categories
.map(
(category) => buildCategoryChip(
category.categoryName ?? '',
isSelected:
state.selectedCategoryId == category.id,
onTap: () {
bloc.add(
FetchMyPassesAttractionsByCategory(
cityXid: cityXid,
categoryXid: category.id,
),
);
},
),
)
.toList(),
),
),
const SizedBox(height: 10),
// 🏙️ Attraction list with search filter
if (state is MyPassesAttractionsLoading)
const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(),
),
)
else if (state is MyPassesAttractionsLoaded)
_buildAttractionsList(state)
else if (state is MyPassesAttractionsError)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
state.message,
style: TextStyle(
color: Colors.red,
fontSize: 14.sp,
),
),
),
)
else
const SizedBox(),
],
),
),
),
);
},
),
);
}
// Helper method to build attractions list
Widget _buildAttractionsList(MyPassesAttractionsLoaded state) {
if (state.filteredAttractions.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
state.searchQuery.isEmpty
? "No attractions found"
: "No attractions match your search",
style: TextStyle(
color: Colors.grey,
fontSize: 14.sp,
),
),
),
);
}
return Column(
children: state.filteredAttractions
.map(
(attraction) => PassAttractionCard(
attraction: attraction,
),
)
.toList(),
);
}
}

View File

@@ -0,0 +1,655 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../common_packages/app_bar.dart';
import '../../common_packages/back_widget.dart';
import '../../common_packages/custom_dash_border_painter.dart';
import '../../core/route_constants.dart';
import '../../networkApiServices/api_urls.dart';
import '../blocs/myPassesDetails/my_passes_details_bloc.dart';
import '../blocs/myPassesDetails/my_passes_details_event.dart';
import '../blocs/myPassesDetails/my_passes_details_state.dart';
class PassDetailsView extends StatefulWidget {
final int bookingId;
const PassDetailsView({super.key, required this.bookingId});
@override
State<PassDetailsView> createState() => _PassDetailsViewState();
}
class _PassDetailsViewState extends State<PassDetailsView> {
@override
void initState() {
super.initState();
context.read<MyPassesDetailsBloc>().add(
FetchMyPassDetails(passId: widget.bookingId),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<MyPassesDetailsBloc, MyPassesDetailsState>(
builder: (context, state) {
if (state is MyPassesDetailsLoading) {
return const Scaffold(
backgroundColor: Colors.white,
body: Center(child: CircularProgressIndicator()),
);
}
if (state is MyPassesDetailsError) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text(
'Error: ${state.message}',
style: GoogleFonts.poppins(color: Colors.red),
),
),
);
}
if (state is MyPassesDetailsLoaded) {
final data = state.data;
final city = data.city;
final attractions = data.attractions ?? [];
final offers = data.offers ?? [];
return SafeArea(
child: Scaffold(
backgroundColor: Colors.white,
body: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// App Bar
SizedBox(height: 10.h),
const CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
SizedBox(height: 10.h),
backWidget(context, "Back", Colors.black),
SizedBox(height: 20.h),
/// -------------------------------
/// UNLIMITED CARD CONTAINER
/// -------------------------------
CustomPaint(
painter: DashedBorderPainter(
color: const Color(0xffF95F62),
radius: 20.r,
),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 18.w, vertical: 18.h),
decoration: BoxDecoration(
color: const Color(0xffF95F62).withOpacity(0.08),
borderRadius: BorderRadius.circular(20.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// Title
Text(
'${(city?.cardMode ?? '').isNotEmpty
? city!.cardMode![0].toUpperCase() + city.cardMode!.substring(1)
: ''} Card',
style: GoogleFonts.poppins(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: const Color(0xffF95F62),
),
),
SizedBox(height: 18.h),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// IMAGE
ClipRRect(
borderRadius: BorderRadius.circular(14.r),
child: Image.asset(
"assets/images/unlimited_card_details.png",
height: 100.w,
width: 100.w,
fit: BoxFit.contain,
),
),
SizedBox(width: 14.w),
/// RIGHT CONTENT
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
/// Adults + Kids (WRAP prevents overflow)
Wrap(
spacing: 10.w,
runSpacing: 10.h,
children: [
_infoChip(
imagePath: "assets/icons/person.png",
text: "Adults-${city?.totalAdult ?? 0}",
),
_infoChip(
imagePath: "assets/icons/person.png",
text: "Kids-${city?.totalChild ?? 0}",
),
],
),
SizedBox(height: 12.h),
/// Days Container (Full width)
_infoChip(
imagePath: "assets/icons/time.png",
text: "${city?.noOfDays ?? 0} Days",
isExpanded: true,
),
SizedBox(height: 14.h),
/// Valid Till
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
"assets/icons/calendar.png",
height: 15.h,
width: 15.w,
color: const Color(0xffF95F62),
),
SizedBox(width: 6.w),
/// "Valid till:" → Black
Text(
"Valid till: ",
style: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
/// Date → Red
Text(
city?.validUpto ?? "",
style: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: const Color(0xffF95F62),
),
),
],
),
),
],
),
),
],
),
],
),
),
),
SizedBox(height: 24.h),
_sectionTitle("Suggested Attractions"),
SizedBox(height: 12.h),
/// Display attractions from API
if (attractions.isNotEmpty) ...[
...attractions.take(2).map((attraction) =>
Padding(
padding: EdgeInsets.only(bottom: 12.h),
child: GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.passAttractionDetails,
arguments: attraction.id,
);
},
child: _attractionCard(
title: attraction.title,
description: attraction.description,
image: attraction.image,
ticketPriceAdult: attraction.ticketPriceAdult,
ticketPriceChild: attraction.ticketPriceChild,
bookingEmail: attraction.bookingEmail,
bookingPhoneNumber: attraction.bookingPhoneNumber,
),
),
)),
] else ...[
_attractionCard(
title: 'No attractions available',
description: '',
image: '',
ticketPriceAdult: null,
ticketPriceChild: null,
bookingEmail: null,
bookingPhoneNumber: null,
),
],
SizedBox(height: 16.h),
_outlineButton(
"View all Attractions",
() {
Navigator.pushNamed(
context,
RouteConstants.passAttractionsPage,
arguments: {
'cityId': city?.id,
'source': 'my_passes',
},
);
},
),
SizedBox(height: 24.h),
/// -------------------------------
/// RECOMMENDED OFFERS
/// -------------------------------
_sectionTitle("Recommended Offers"),
SizedBox(height: 12.h),
/// Display offers from API
if (offers.isNotEmpty) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.offerPassDetail,
arguments: offers[0].id,
);
},
child: _offerCard(
title: offers[0].title ?? '',
description: offers[0].description ?? '',
image: offers[0].mobileBannerImage != null
? "${ApiUrls.baseUrl}/${offers[0].mobileBannerImage!}"
: '',
),
),
),
if (offers.length > 1) ...[
SizedBox(width: 12.w),
Expanded(
child: GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.offerPassDetail,
arguments: offers[1].id,
);
},
child: _offerCard(
title: offers[1].title ?? '',
description: offers[1].description ?? '',
image: offers[1].mobileBannerImage != null
? "${ApiUrls.baseUrl}/${offers[1].mobileBannerImage!}"
: '',
),
),
),
],
],
),
] else ...[
Row(
children: [
Expanded(
child: _offerCard(
title: 'No offers available',
description: '',
image: '',
),
),
],
),
],
SizedBox(height: 16.h),
_outlineButton(
"View all Offers",
() {
Navigator.pushNamed(
context,
RouteConstants.searchPassOffer,
arguments: city?.id ??"",
);
},
),
SizedBox(height: 20.h),
GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.privacyPolicy,
);
},
child: Center(
child: Text(
"Learn about policies",
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
),
),
),
),
SizedBox(height: 30.h),
],
),
),
),
);
}
return const Scaffold(
backgroundColor: Colors.white,
body: Center(child: CircularProgressIndicator()),
);
},
);
}
Widget _sectionTitle(String title) {
return Text(
title,
style: GoogleFonts.poppins(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
);
}
Widget _outlineButton(String title, VoidCallback onTap) {
return InkWell(
onTap: onTap,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 14.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30.r),
border: Border.all(color: const Color(0xffF95F62)),
),
child: Center(
child: Text(
title,
style: GoogleFonts.poppins(
color: const Color(0xffF95F62),
fontWeight: FontWeight.w600,
),
),
),
),
);
}
Widget _attractionCard({
required String title,
required String description,
required String image,
num? ticketPriceAdult,
num? ticketPriceChild,
String? bookingEmail,
String? bookingPhoneNumber,
}) {
// Check if booking is required (both email and phone are empty/null)
final bool isBookingRequired = (bookingEmail == null || bookingEmail.isEmpty) &&
(bookingPhoneNumber == null || bookingPhoneNumber.isEmpty);
// Format the price display
String priceText = ticketPriceAdult != null
? "from \$${ticketPriceAdult}/person"
: "Price not available";
return Container(
padding: EdgeInsets.all(10.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: const Color(0xffF2D6D6)),
),
child: Row(
children: [
/// 🔥 Attraction Image (Real Image Style Box)
ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: image.isNotEmpty
? Image.network(
image,
height: 100.w,
width: 90.w,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
"assets/images/aa4.png",
height: 100.w,
width: 90.w,
fit: BoxFit.cover,
);
},
)
: Image.asset(
"assets/images/aa4.png",
height: 100.w,
width: 90.w,
fit: BoxFit.cover,
),
),
SizedBox(width: 12.w),
/// 🔥 Text Section
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 14.sp,
),
),
SizedBox(height: 2.h),
Text(
description,
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.grey.shade600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4.h),
Text(
priceText,
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 6.h),
// Show "Booking Required" tag only if both email and phone are null/empty
if (isBookingRequired)
Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8.r),
),
child: Text(
"Booking Required",
style: GoogleFonts.poppins(
fontSize: 10.sp,
color: Colors.blue.shade700,
),
),
),
],
),
),
SizedBox(width: 8.w),
/// 🔥 QR Code Circle (Proper UI like Design)
Container(
height: 44.w,
width: 44.w,
decoration: BoxDecoration(
color: const Color(0xffF8EDED), // light pink circle bg
shape: BoxShape.circle,
),
child: Padding(
padding: EdgeInsets.all(10.w),
child: Image.asset(
"assets/images/qr_image.png",
fit: BoxFit.contain,
),
),
),
],
),
);
}
Widget _infoChip({
required String imagePath, // 👈 image asset path
required String text,
bool isExpanded = false,
}) {
return Container(
width: isExpanded ? double.infinity : null,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xffF95F62)),
borderRadius: BorderRadius.circular(14.r),
),
child: Row(
mainAxisSize:
isExpanded ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment:
isExpanded ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
Image.asset(
imagePath,
height: 14.h,
width: 14.w,
color: const Color(0xffF95F62), // remove if your icon has its own color
),
SizedBox(width: 6.w),
Text(
text,
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: const Color(0xffF95F62),
),
),
],
),
);
}
Widget _offerCard({
required String title,
required String description,
required String image,
}) {
return Container(
padding: EdgeInsets.all(6.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: const Color(0xffF2D6D6)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// 🔥 Top Offer Image
ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: image.isNotEmpty
? Image.network(
image,
height: 120.h,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(
"assets/images/aa4.png",
height: 120.h,
width: double.infinity,
fit: BoxFit.cover,
);
},
)
: Image.asset(
"assets/images/aa4.png",
height: 120.h,
width: double.infinity,
fit: BoxFit.cover,
),
),
SizedBox(height: 12.h),
/// 🔥 Title
Text(
title,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 16.sp,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 6.h),
/// 🔥 Description
Text(
description,
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.grey.shade700,
height: 1.4,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}

View File

@@ -1,144 +0,0 @@
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../common_packages/app_bar.dart';
import '../../common_packages/back_widget.dart';
import '../../core/route_constants.dart';
import '../widgets/action_button_widget.dart';
import '../widgets/qr_container_widget.dart';
class QrPassView extends StatelessWidget {
const QrPassView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<MyPassBloc, MyPassState>(
builder: (context, state) {
if (state is MyPassLoaded) {
final pass = state.selectedPass!;
return SafeArea(
child: Scaffold(
backgroundColor: Colors.white,
body: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,),
SizedBox(height: 10.h),
backWidget(context, "Back", Colors.black),
SizedBox(height: 20.h),
SizedBox(height: 10.h),
Text(
"Scan this at the site of\nattraction",
textAlign: TextAlign.center,
style: GoogleFonts.poppins(
fontSize: 13.sp,
color: Colors.black87,
),
),
SizedBox(height: 20.h),
/// ♻️ Reusable QR Container Component
QrContainerWidget(
qrImagePath: "assets/images/qr_image.png",
cityCardTitle: "Melbourne CityCards",
qrCode: "IYFHHVN254ADSD",
cardType: pass.title,
),
SizedBox(height: 24.h),
/// 🎟 Card details section
Container(
padding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 40,
),
decoration: BoxDecoration(
color: pass.title.toLowerCase() == "unlimited card"
? const Color(0xffF95F62).withOpacity(0.1)
: const Color(0xffF95FAF).withOpacity(0.1),
borderRadius: BorderRadius.circular(25.r),
border: Border.all(
color: pass.title.toLowerCase() == "unlimited card"
? const Color(0xffF95F62)
: const Color(0xffF95FAF),
),
),
child: Text(
pass.title,
style: GoogleFonts.poppins(
fontSize: 16.sp,
color: const Color(0xffFF5A5F),
fontWeight: FontWeight.w500,
),
),
),
SizedBox(height: 6.h),
Text(
"Adults-${pass.adults} • Kids-${pass.kids}${pass.duration}",
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Color(0xff212121),
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 4.h),
Text(
"Valid Till: ${pass.validity}",
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Color(0xff212121),
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 28.h),
Align(
alignment: Alignment.centerLeft,
child: Text(
"Learn about policies",
style: GoogleFonts.poppins(
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
),
),
),
SizedBox(height: 24.h),
/// 🔘 Buttons
Column(
children: [
actionButton(
label: "View All Attractions",
onPressed: () {
Navigator.of(context).pushNamed(RouteConstants.attractionsPage, arguments: "qrPass");
},
),
SizedBox(height: 12.h),
actionButton(
label: "View All Available Offers",
onPressed: () {
Navigator.of(context).pushNamed(RouteConstants.searchOffer);
},
),
],
),
],
),
),
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}
}

View File

@@ -0,0 +1,409 @@
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:flutter/material.dart';
import 'package:flutter/services.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';
import '../blocs/myPassesOffers/my_passes_offers_bloc.dart';
import '../blocs/myPassesOffers/my_passes_offers_event.dart';
import '../blocs/myPassesOffers/my_passes_offers_state.dart';
import '../repository/my_passes_offers_repository.dart';
class PassOffersScreen extends StatefulWidget {
final int cityId;
const PassOffersScreen({
super.key,
required this.cityId,
});
@override
State<PassOffersScreen> createState() => _PassOffersScreenState();
}
class _PassOffersScreenState extends State<PassOffersScreen> {
int? selectedCategoryId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository())
..add(LoadMyPassesOffers(cityXid: widget.cityId)),
child: Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showCart: false,
showDivider: true,
),
Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Icon(Icons.arrow_back),
),
SizedBox(width: 8.w),
CustomText(
text: "Offers with ${CommonAppText.selectiveCard} Card",
size: 12.sp,
),
],
),
SizedBox(height: 33.h),
Builder(
builder: (context) => CommonSearchField(
hint: "Search offers",
hintColor: const Color(0xFFF95F62).withOpacity(.6),
showSuffix: true,
onChanged: (value) {
context.read<MyPassesOffersBloc>().add(SearchMyPassesOffers(value));
},
),
),
SizedBox(height: 20.h),
/// Dynamic Categories
BlocBuilder<MyPassesOffersBloc, MyPassesOffersState>(
builder: (context, state) {
if (state is MyPassesOffersLoaded) {
final categories = state.categories;
if (categories.isEmpty) {
return SizedBox.shrink();
}
return Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
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<MyPassesOffersBloc>()
.add(LoadMyPassesOffers(cityXid: widget.cityId));
} else {
// Select new category
selectedCategoryId = category.id;
context.read<MyPassesOffersBloc>().add(
LoadMyPassesOffers(
cityXid: widget.cityId,
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<MyPassesOffersBloc, MyPassesOffersState>(
builder: (context, state) {
if (state is MyPassesOffersLoading) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFFF95F62),
),
);
}
if (state is MyPassesOffersError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48.sp,
color: Colors.red,
),
SizedBox(height: 16.h),
Text(
state.message,
style: TextStyle(
color: Colors.red,
fontSize: 14.sp,
),
textAlign: TextAlign.center,
),
],
),
);
}
if (state is MyPassesOffersLoaded) {
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,
),
),
],
),
);
}
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(
crossAxisAlignment: CrossAxisAlignment.start,
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: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 8.h),
Expanded(
child: CustomText(
text: offer.description,
color: Colors.black.withOpacity(.6),
size: 12.sp,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (offer.offerCode != null && offer.offerCode!.isNotEmpty) ...[
SizedBox(height: 8.h),
GestureDetector(
onTap: () {
Clipboard.setData(
ClipboardData(text: offer.offerCode!),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Code copied: ${offer.offerCode!}"),
duration: Duration(seconds: 1),
backgroundColor: Color(0xFFF95F62),
),
);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w,
vertical: 6.h,
),
decoration: BoxDecoration(
color: Color(0xFFFEE7E7),
borderRadius: BorderRadius.circular(6.sp),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: CustomText(
text: offer.offerCode!,
size: 12.sp,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.copy,
size: 16.sp,
color: Color(0xFFF95F62),
),
],
),
),
),
],
],
),
),
);
},
);
}
return const Center(
child: Text(
"No data available",
style: TextStyle(color: Colors.grey, fontSize: 14),
),
);
},
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../attractions/models/attraction_model.dart';
import '../../common_packages/common_app_texts.dart';
import '../../core/route_constants.dart';
class PassAttractionCard extends StatelessWidget {
final Attraction attraction;
const PassAttractionCard({super.key, required this.attraction});
@override
Widget build(BuildContext context) {
/// CARD TITLES (instead of categories)
final List<String> tags = attraction.cards
.map((e) => e.title)
.where((e) => e.isNotEmpty)
.toList();
/// GALLERY IMAGE (handled safely in model)
final String imageUrl = attraction.coverImageUrl;
/// Show "Booking Required" when both email and phone are empty/null
final bool showBookingRequired =
(attraction.bookingEmail.isEmpty || attraction.bookingEmail == null) ||
(attraction.bookingPhoneNumber.isEmpty || attraction.bookingPhoneNumber == null);
/// Format the price display
String priceText = attraction.ticketPriceAdult != null
? "from \$${attraction.ticketPriceAdult}/person"
: "Price not available";
return InkWell(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.passAttractionDetails,
arguments: attraction.id,
);
},
child: Container(
margin: EdgeInsets.symmetric(vertical: 8.h, horizontal: 8.w),
padding: EdgeInsets.all(10.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: const Color(0xffF2D6D6)),
),
child: Row(
children: [
/// 🔥 Attraction Image (Real Image Style Box)
ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: imageUrl.isNotEmpty
? Image.network(
imageUrl,
height: 100.w,
width: 90.w,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _imageFallback();
},
)
: _imageFallback(),
),
SizedBox(width: 12.w),
/// 🔥 Text Section
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
attraction.title,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 14.sp,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 2.h),
Text(
attraction.description,
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.grey.shade600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4.h),
Text(
priceText,
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 6.h),
/// TAGS (CARD TITLES) OR BOOKING REQUIRED
showBookingRequired
? Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: const Color(0xffC1D2F8),
border: Border.all(
color: const Color(0xff2563EB),
),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
"Booking Required",
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: const Color(0xff1A1A1A),
fontWeight: FontWeight.w400,
),
),
)
: Wrap(
spacing: 6.w,
runSpacing: 6.h,
children: tags
.map(
(tag) => Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: tag ==
"${CommonAppText.selectiveCard} Card"
? const Color(0xffF95FAF)
.withOpacity(0.1)
: const Color(0xffF95F62)
.withOpacity(0.1),
border: Border.all(
color: tag ==
"${CommonAppText.selectiveCard} Card"
? const Color(0xffF95FAF)
: const Color(0xffF95F62),
),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
tag,
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: const Color(0xff1A1A1A),
fontWeight: FontWeight.w400,
),
),
),
)
.toList(),
),
],
),
),
SizedBox(width: 8.w),
/// 🔥 QR Code Circle (Proper UI like Design)
Container(
height: 44.w,
width: 44.w,
decoration: const BoxDecoration(
color: Color(0xffF8EDED), // light pink circle bg
shape: BoxShape.circle,
),
child: Padding(
padding: EdgeInsets.all(10.w),
child: Image.asset(
"assets/images/qr_image.png",
fit: BoxFit.contain,
),
),
),
],
),
),
);
}
/// Image Fallback Widget
Widget _imageFallback() {
return Image.asset(
"assets/images/aa4.png",
height: 100.w,
width: 90.w,
fit: BoxFit.cover,
);
}
}

View File

@@ -1,24 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import '../models/my_passes_model.dart';
class PassTicketCard extends StatelessWidget {
final dynamic pass;
final MyPassData pass;
const PassTicketCard({super.key, required this.pass});
@override
Widget build(BuildContext context) {
// Dimensions tuned to your screenshot
final double cardWidth = MediaQuery.of(context).size.width - 32.w;
final double topSectionHeight = 105.h; // where dotted line sits
final double topSectionHeight = 105.h;
final double bottomSectionHeight = 50.h;
final double cardHeight = topSectionHeight + bottomSectionHeight;
return SizedBox(
width: cardWidth,
child: CustomPaint(
// paints white background, border, corner radius, side cuts, shadow, and divider dots
painter: _TicketBackgroundPainter(
cornerRadius: 16.r,
notchRadius: 9.r,
@@ -27,7 +26,6 @@ class PassTicketCard extends StatelessWidget {
shadowColor: Colors.black.withOpacity(0.08),
),
child: ClipPath(
// actual clipping so child content never bleeds outside the shape
clipper: _TicketClipper(
cornerRadius: 16.r,
notchRadius: 9.r,
@@ -37,32 +35,36 @@ class PassTicketCard extends StatelessWidget {
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
child: Column(
children: [
// ---------- TOP SECTION ----------
SizedBox(
height: topSectionHeight - 12.h, // keep space for the dots line
height: topSectionHeight - 12.h,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// thumbnail
ClipRRect(
borderRadius: BorderRadius.circular(10.r),
child: Image.asset(
pass.imageUrl,
child: Image.network(
pass.city?.bannerImage ?? '',
height: 80.h,
width: 80.w,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 80.h,
width: 80.w,
color: Colors.grey[300],
child: Icon(Icons.image, size: 40),
);
},
),
),
SizedBox(width: 10.w),
// details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (pass.isActive)
if (pass.bookingStatus == "active")
Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 3.h),
@@ -81,7 +83,7 @@ class PassTicketCard extends StatelessWidget {
),
SizedBox(width: 8.w),
Text(
pass.duration, // "2 Days"
"${pass.noOfDays ?? 0} Days",
style: GoogleFonts.poppins(
color: Colors.black87,
fontSize: 12.sp,
@@ -91,7 +93,9 @@ class PassTicketCard extends StatelessWidget {
),
SizedBox(height: 10.h),
Text(
pass.title,
"${(pass.cardMode?.isNotEmpty ?? false)
? pass.cardMode![0].toUpperCase() + pass.cardMode!.substring(1)
: ''} Card",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 18.sp,
@@ -100,7 +104,7 @@ class PassTicketCard extends StatelessWidget {
),
SizedBox(height: 4.h),
Text(
"Adults-${pass.adults} • Kids-${pass.kids}",
"Adults-${pass.totalAdult ?? 0} • Kids-${pass.totalChild ?? 0}",
style: GoogleFonts.poppins(
color: Colors.black54,
fontSize: 11.sp,
@@ -109,8 +113,6 @@ class PassTicketCard extends StatelessWidget {
],
),
),
// QR chip
CircleAvatar(
radius: 20.r,
backgroundColor: Color(0xffFEE7E7),
@@ -122,26 +124,21 @@ class PassTicketCard extends StatelessWidget {
],
),
),
// space exactly where the dotted line is painted by the painter
SizedBox(height: 15.h),
// ---------- BOTTOM SECTION ----------
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Valid Till: ${pass.validity}",
"Valid Till: ${pass.validUpto ?? ''}",
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: Colors.black,
fontWeight: FontWeight.w400
),
fontWeight: FontWeight.w400),
),
Text(
pass.city, // "Melbourne"
pass.city?.name ?? '',
style: GoogleFonts.poppins(
fontWeight: FontWeight.w500,
fontSize: 13.sp,
@@ -159,7 +156,6 @@ class PassTicketCard extends StatelessWidget {
}
}
/// Clips the ticket with rounded corners and 2 side “cuts” centered at dividerY
class _TicketClipper extends CustomClipper<Path> {
final double cornerRadius;
final double notchRadius;
@@ -180,10 +176,11 @@ class _TicketClipper extends CustomClipper<Path> {
));
final cuts = Path()
..addOval(Rect.fromCircle(center: Offset(0, dividerY), radius: notchRadius))
..addOval(Rect.fromCircle(center: Offset(size.width, dividerY), radius: notchRadius));
..addOval(Rect.fromCircle(
center: Offset(0, dividerY), radius: notchRadius))
..addOval(Rect.fromCircle(
center: Offset(size.width, dividerY), radius: notchRadius));
// Rounded-rect MINUS the two circles
return Path.combine(PathOperation.difference, rrectPath, cuts);
}
@@ -194,8 +191,6 @@ class _TicketClipper extends CustomClipper<Path> {
dividerY != old.dividerY;
}
/// Paints fill, border, shadow and the dotted perforation line
class _TicketBackgroundPainter extends CustomPainter {
final double cornerRadius;
final double notchRadius;
@@ -224,35 +219,30 @@ class _TicketBackgroundPainter extends CustomPainter {
void paint(Canvas canvas, Size size) {
final path = _ticketPath(size);
// Realistic layered shadow
canvas.save();
canvas.translate(0, 2); // tiny downward offset for depth
canvas.translate(0, 2);
final shadowPaint = Paint()
..color = Colors.black.withOpacity(0.10)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
canvas.drawPath(path, shadowPaint);
canvas.restore();
// Subtle ambient shadow (light spread around)
final ambientShadowPaint = Paint()
..color = Colors.black.withOpacity(0.04)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
canvas.drawPath(path, ambientShadowPaint);
// Fill background
final fillPaint = Paint()
..style = PaintingStyle.fill
..color = const Color(0xffFFFBFB);
canvas.drawPath(path, fillPaint);
// Border stroke
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = const Color(0xffE5E5E5);
canvas.drawPath(path, strokePaint);
// 🔹 Dotted perforation line
final dashPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1

View File

@@ -1,8 +1,8 @@
class ApiUrls {
static const baseUrl =
"https://devapi.citycards.betadelivery.com"; //Normal API
// static const baseUrl = "https://devapi.citycards.betadelivery.com";//Normal API
// static const baseUrl = "https://testingapi.citycards.betadelivery.com";// Test API
// static const baseUrl = "https://uatapi.citycard.betadelivery.com";// Production Lvl API
static const baseUrl = "https://uatapi.citycard.betadelivery.com";// Production Lvl API
static const refreshToken = "$baseUrl/auth/refresh";
@@ -10,15 +10,20 @@ class ApiUrls {
// static const upcomingCityList = "$baseUrl/mobile/upcoming_cities";
static const searchCityList = "$baseUrl/mobile/city-selection";
static const attractionsList = "$baseUrl/mobile/list/all";
static const passAttractionsList = "$baseUrl/mobile/passes/mobile/list";
static const attractionDetails = "$baseUrl/mobile/list";
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 passOffers = "$baseUrl/mobile/passes/mobile/list/offers";
static const buyAPass = "$baseUrl/mobile/pass";
static const offersDetails = "$baseUrl/mobile/list/offers";
static const myPostCards = "$baseUrl/mobile/postcards/all";
static const coupons = "$baseUrl/mobile/passes/dropdown/card";
static const myPasses = "$baseUrl/mobile/passes/all";
static const passDetails = "$baseUrl/mobile/passes";
static const myPassesCart = "$baseUrl/mobile/passes/cart/passes";
static const editPostcard = "$baseUrl/mobile/postcards";

View File

@@ -24,7 +24,7 @@ class OffersDetailsView extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => OfferDetailsBloc(
repository: OffersDetailsRepository(), // Create directly
repository: OffersDetailsRepository(), // Create directly
)..add(FetchOfferDetailsEvent(offerId: offerId)),
child: const _OffersDetailsContent(),
);
@@ -106,13 +106,17 @@ class _OffersDetailsContent extends StatelessWidget {
),
),
SizedBox(width: 8.w),
Text(
Expanded(
child: Text(
offer.partnerName,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
@@ -125,6 +129,7 @@ class _OffersDetailsContent extends StatelessWidget {
Positioned(
bottom: 31.h,
left: 12.w,
right: 60.w,
child: Text(
offer.partnerName,
style: TextStyle(
@@ -133,6 +138,8 @@ class _OffersDetailsContent extends StatelessWidget {
fontWeight: FontWeight.w500,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),

View File

@@ -0,0 +1,63 @@
import 'dart:developer';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/postcard_add_to_cart_repository.dart';
import 'add_to_cart_postcard_event.dart';
import 'add_to_cart_postcard_state.dart';
class AddToCartPostCardBloc
extends Bloc<AddToCartPostCardEvent, AddToCartPostCardState> {
final AddToCartPostCardRepository repository;
AddToCartPostCardBloc(this.repository)
: super(AddToCartPostCardInitial()) {
on<AddToCartPostCardRequested>(_onAddToCartRequested);
}
Future<void> _onAddToCartRequested(
AddToCartPostCardRequested event,
Emitter<AddToCartPostCardState> emit,
) async {
try {
emit(AddToCartPostCardLoading());
final response = await repository.addToCartPostCard(
countryName: event.countryName,
cityName: event.cityName,
stateName: event.stateName,
zipCode: event.zipCode,
address1: event.address1,
address2: event.address2,
pcTitle: event.pcTitle,
pcContent: event.pcContent,
pcImageFile: event.pcImageFile,
pcNumber: event.pcNumber,
pcDatetime: event.pcDatetime,
fullname: event.fullname,
emailAddress: event.emailAddress,
mobileNumber: event.mobileNumber,
isdCode: event.isdCode,
isForSelf: true, // API default
isDraft: true, // API default
baseAmount: 0,
totalTaxAmount: 0,
totalAmount: 0,
);
final postcard = response['postcard'];
emit(
AddToCartPostCardSuccess(
postcardId: postcard['id'],
pcNumber: postcard['pcNumber'],
baseAmount: (postcard['baseAmount'] as num).toDouble(),
totalTaxAmount: (postcard['totalTaxAmount'] as num).toDouble(),
totalAmount: (postcard['totalAmount'] as num).toDouble(),
pcDatetime: postcard['pcDatetime'],
),
);
} catch (e) {
log('❌ AddToCartPostCardBloc Error', error: e);
emit(AddToCartPostCardFailure(e.toString()));
}
}
}

View File

@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:equatable/equatable.dart';
abstract class AddToCartPostCardEvent extends Equatable {
const AddToCartPostCardEvent();
@override
List<Object?> get props => [];
}
class AddToCartPostCardRequested extends AddToCartPostCardEvent {
final String countryName;
final String cityName;
final String stateName;
final String zipCode;
final String? address1;
final String? address2;
final String pcTitle;
final String pcContent;
final File pcImageFile;
final String pcNumber;
final String pcDatetime;
final String fullname;
final String emailAddress;
final String mobileNumber;
final String isdCode;
AddToCartPostCardRequested({
required this.countryName,
required this.cityName,
required this.stateName,
required this.zipCode,
this.address1,
this.address2,
required this.pcTitle,
required this.pcContent,
required this.pcImageFile,
required this.pcNumber,
required this.pcDatetime,
required this.fullname,
required this.emailAddress,
required this.mobileNumber,
required this.isdCode,
});
@override
List<Object?> get props => [
countryName,
cityName,
stateName,
zipCode,
address1,
address2,
pcTitle,
pcContent,
pcImageFile,
pcNumber,
pcDatetime,
fullname,
emailAddress,
mobileNumber,
isdCode,
];
}

View File

@@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
abstract class AddToCartPostCardState extends Equatable {
const AddToCartPostCardState();
@override
List<Object?> get props => [];
}
class AddToCartPostCardInitial extends AddToCartPostCardState {}
class AddToCartPostCardLoading extends AddToCartPostCardState {}
class AddToCartPostCardSuccess extends AddToCartPostCardState {
final int postcardId;
final String pcNumber;
final double baseAmount;
final double totalTaxAmount;
final double totalAmount;
final String pcDatetime;
const AddToCartPostCardSuccess({
required this.postcardId,
required this.pcNumber,
required this.baseAmount,
required this.totalTaxAmount,
required this.totalAmount,
required this.pcDatetime,
});
@override
List<Object?> get props => [
postcardId,
pcNumber,
baseAmount,
totalTaxAmount,
totalAmount,
];
}
class AddToCartPostCardFailure extends AddToCartPostCardState {
final String message;
const AddToCartPostCardFailure(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -1,5 +1,4 @@
import 'dart:developer';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/postcard_checkout_repository.dart';
import 'postcard_checkout_event.dart';
@@ -16,7 +15,7 @@ class PostcardCheckoutBloc
on<UpdateCheckoutDataEvent>(_onUpdateCheckoutData);
on<SaveAsDraftEvent>(_onSaveAsDraft);
on<SubmitPostcardEvent>(_onSubmitPostcard);
on<ConfirmPaymentEvent>(_onConfirmPayment); // 🆕 NEW
on<ConfirmPaymentEvent>(_onConfirmPayment);
}
void _onUpdateAddress(
@@ -73,6 +72,7 @@ class PostcardCheckoutBloc
baseAmount: event.baseAmount,
totalTaxAmount: event.totalTaxAmount,
totalAmount: event.totalAmount,
postcardId: event.postcardId,
),
);
}
@@ -84,6 +84,16 @@ class PostcardCheckoutBloc
emit(state.copyWith(isLoading: true, error: null, isSuccess: false));
try {
// Validate pcId exists
if (state.postcardId == null) {
emit(state.copyWith(
isLoading: false,
error: 'Postcard ID is missing',
isSuccess: false,
));
return;
}
// Validate that image file exists before submitting
if (state.pcImageFile == null) {
emit(
@@ -97,31 +107,12 @@ class PostcardCheckoutBloc
}
final response = await repository.createPostCard(
countryName: state.countryName,
cityName: state.cityName,
stateName: state.stateName,
zipCode: state.zipCode,
address1: state.address1.isNotEmpty ? state.address1 : null,
address2: state.address2.isNotEmpty ? state.address2 : null,
pcTitle: state.pcTitle,
pcContent: state.pcContent,
pcImageFile: state.pcImageFile!,
pcNumber: state.pcNumber,
pcDatetime: state.pcDatetime,
fullname: state.fullname,
emailAddress: state.emailAddress,
mobileNumber: state.mobileNumber,
isdCode: state.isdCode,
isForSelf: state.isForSelf,
isDraft: true, // Save as draft
baseAmount: state.baseAmount,
totalTaxAmount: state.totalTaxAmount,
totalAmount: state.totalAmount,
pcId: state.postcardId!,
isDraft: true,
);
// Extract order ID from response if available
final orderId =
response['orderId']?.toString() ??
final orderId = response['orderId']?.toString() ??
response['order_id']?.toString() ??
response['id']?.toString();
@@ -135,7 +126,11 @@ class PostcardCheckoutBloc
);
} catch (e) {
emit(
state.copyWith(isLoading: false, error: e.toString(), isSuccess: false),
state.copyWith(
isLoading: false,
error: e.toString(),
isSuccess: false,
),
);
}
}
@@ -147,6 +142,16 @@ class PostcardCheckoutBloc
emit(state.copyWith(isLoading: true, error: null, isSuccess: false));
try {
// Validate pcId exists
if (state.postcardId == null) {
emit(state.copyWith(
isLoading: false,
error: 'Postcard ID is missing',
isSuccess: false,
));
return;
}
// Validate that image file exists before submitting
if (state.pcImageFile == null) {
emit(
@@ -160,37 +165,16 @@ class PostcardCheckoutBloc
}
final response = await repository.createPostCard(
countryName: state.countryName,
cityName: state.cityName,
stateName: state.stateName,
zipCode: state.zipCode,
address1: state.address1.isNotEmpty ? state.address1 : null,
address2: state.address2.isNotEmpty ? state.address2 : null,
pcTitle: state.pcTitle,
pcContent: state.pcContent,
pcImageFile: state.pcImageFile!,
pcNumber: state.pcNumber,
pcDatetime: state.pcDatetime,
fullname: state.fullname,
emailAddress: state.emailAddress,
mobileNumber: state.mobileNumber,
isdCode: state.isdCode,
isForSelf: state.isForSelf,
isDraft: false, // Final submission (payment)
baseAmount: state.baseAmount,
totalTaxAmount: state.totalTaxAmount,
totalAmount: state.totalAmount,
pcId: state.postcardId!,
isDraft: false,
);
// 🆕 Parse response from backend
// Expected: {"postcardId": 16, "clientSecret": "pi_3Sx0yjRtCkWyT4Em1MKw1FeU_secret_S8M74wnEhTRC9lUz9RqJnuuqg"}
// Parse response from backend
final postcardId = response['postcardId'] as int?;
final clientSecret = response['clientSecret'] as String?;
// Also try alternative key names in case backend uses different naming
final orderId =
response['orderId']?.toString() ??
// Extract order ID from response
final orderId = response['orderId']?.toString() ??
response['order_id']?.toString() ??
response['id']?.toString();
@@ -199,36 +183,38 @@ class PostcardCheckoutBloc
emit(
state.copyWith(
isLoading: false,
error:
'Payment initialization failed - no client secret received from server',
error: 'Payment initialization failed - no client secret received',
isSuccess: false,
),
);
return;
}
// 🆕 Emit success with clientSecret for payment processing
// Emit success with clientSecret for payment processing
emit(
state.copyWith(
isLoading: false,
isSuccess: true,
isDraft: false,
postcardId: postcardId,
clientSecret: clientSecret, // This will trigger payment flow
postcardId: postcardId ?? state.postcardId,
clientSecret: clientSecret,
orderId: orderId,
),
);
} catch (e, stack) {
log("Payment Error ${e.toString()}");
log("Payment Error ${stack.toString()}");
log("Payment Error: ${e.toString()}");
log("Payment Stack: ${stack.toString()}");
emit(
state.copyWith(isLoading: false, error: e.toString(), isSuccess: false),
state.copyWith(
isLoading: false,
error: e.toString(),
isSuccess: false,
),
);
}
}
/// 🆕 Confirm payment after Stripe payment completes
/// This should be called after Stripe payment succeeds or fails
/// Confirm payment after Stripe payment completes
Future<void> _onConfirmPayment(
ConfirmPaymentEvent event,
Emitter<PostcardCheckoutState> emit,

View File

@@ -44,7 +44,7 @@ class UpdateCheckoutDataEvent extends PostcardCheckoutEvent {
final String? address2;
final String? pcTitle;
final String? pcContent;
final File? pcImageFile; // ⭐ CHANGED: File instead of String
final File? pcImageFile;
final String? pcNumber;
final String? pcDatetime;
final String? fullname;
@@ -55,6 +55,7 @@ class UpdateCheckoutDataEvent extends PostcardCheckoutEvent {
final double? baseAmount;
final double? totalTaxAmount;
final double? totalAmount;
final int? postcardId; // ⭐ ADD THIS
UpdateCheckoutDataEvent({
this.countryName,
@@ -65,7 +66,7 @@ class UpdateCheckoutDataEvent extends PostcardCheckoutEvent {
this.address2,
this.pcTitle,
this.pcContent,
this.pcImageFile, // ⭐ CHANGED
this.pcImageFile,
this.pcNumber,
this.pcDatetime,
this.fullname,
@@ -76,6 +77,7 @@ class UpdateCheckoutDataEvent extends PostcardCheckoutEvent {
this.baseAmount,
this.totalTaxAmount,
this.totalAmount,
this.postcardId, // ⭐ ADD THIS
});
}

View File

@@ -247,6 +247,19 @@ class PostcardCreationBloc
on<TogglePurchaseOption>((event, emit) {
emit(state.copyWith(isGift: event.isGift));
});
on<StoreUserProfileData>((event, emit) {
emit(state.copyWith(
userProfileFullName: event.fullName,
userProfileEmail: event.email,
userProfilePhone: event.phone,
userProfileAddress: event.address,
userProfileCity: event.city,
userProfileState: event.state,
userProfileZipCode: event.zipCode,
userProfileCountry: event.country,
));
});
}
// Add this getter method in PostcardCreationBloc class

View File

@@ -69,3 +69,26 @@ class UpdatePostcardNumber extends PostcardCreationEvent {
UpdatePostcardNumber(this.pcNumber);
}
// Event to store user profile data when "Buy for Myself" is selected
class StoreUserProfileData extends PostcardCreationEvent {
final String? fullName;
final String? email;
final String? phone;
final String? address;
final String? city;
final String? state;
final String? zipCode;
final String? country;
StoreUserProfileData({
this.fullName,
this.email,
this.phone,
this.address,
this.city,
this.state,
this.zipCode,
this.country,
});
}

View File

@@ -20,7 +20,17 @@ class PostcardCreationState {
final String? country;
final String? state;
final String? zipCode;
final String? pcNumber; // 🆕 ADD THIS
final String? pcNumber;
// User's profile data (for "Buy for Myself" option)
final String? userProfileFullName;
final String? userProfileEmail;
final String? userProfilePhone;
final String? userProfileAddress;
final String? userProfileCity;
final String? userProfileState;
final String? userProfileZipCode;
final String? userProfileCountry;
const PostcardCreationState({
required this.currentStep,
@@ -41,7 +51,16 @@ class PostcardCreationState {
this.state,
this.zipCode,
this.pcNumber,
required this.address, // 🆕 ADD THIS
required this.address,
// User profile data
this.userProfileFullName,
this.userProfileEmail,
this.userProfilePhone,
this.userProfileAddress,
this.userProfileCity,
this.userProfileState,
this.userProfileZipCode,
this.userProfileCountry,
});
PostcardCreationState copyWith({
@@ -63,7 +82,16 @@ class PostcardCreationState {
String? country,
String? state,
String? zipCode,
String? pcNumber, // 🆕 ADD THIS
String? pcNumber,
// User profile fields
String? userProfileFullName,
String? userProfileEmail,
String? userProfilePhone,
String? userProfileAddress,
String? userProfileCity,
String? userProfileState,
String? userProfileZipCode,
String? userProfileCountry,
}) {
return PostcardCreationState(
currentStep: currentStep ?? this.currentStep,
@@ -84,7 +112,16 @@ class PostcardCreationState {
country: country ?? this.country,
state: state ?? this.state,
zipCode: zipCode ?? this.zipCode,
pcNumber: pcNumber ?? this.pcNumber, // 🆕 ADD THIS
pcNumber: pcNumber ?? this.pcNumber,
// User profile data
userProfileFullName: userProfileFullName ?? this.userProfileFullName,
userProfileEmail: userProfileEmail ?? this.userProfileEmail,
userProfilePhone: userProfilePhone ?? this.userProfilePhone,
userProfileAddress: userProfileAddress ?? this.userProfileAddress,
userProfileCity: userProfileCity ?? this.userProfileCity,
userProfileState: userProfileState ?? this.userProfileState,
userProfileZipCode: userProfileZipCode ?? this.userProfileZipCode,
userProfileCountry: userProfileCountry ?? this.userProfileCountry,
);
}
}

View File

@@ -0,0 +1,203 @@
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
class AddToCartPostCardRepository {
final NetworkApiService _apiServices = NetworkApiService();
/// Create / Save Postcard (Draft or Final)
/// ⭐ UPDATED: Now uses multipart/form-data for file upload
Future<Map<String, dynamic>> addToCartPostCard({
required String countryName,
required String cityName,
required String stateName,
required String zipCode,
String? address1, // NOT required
String? address2, // NOT required
required String pcTitle,
required String pcContent,
required File pcImageFile, // ⭐ CHANGED: File instead of String
required String pcNumber,
required String pcDatetime,
required String fullname,
required String emailAddress,
required String mobileNumber,
required String isdCode,
required bool isForSelf,
required bool isDraft,
required double baseAmount,
required double totalTaxAmount,
required double totalAmount,
}) async {
try {
log('🟡 createPostCard() called');
if (kDebugMode) {
print('📤 [CREATE POSTCARD] Country: $countryName');
print('📤 [CREATE POSTCARD] City: $cityName');
print('📤 [CREATE POSTCARD] State: $stateName');
print('📤 [CREATE POSTCARD] Zip: $zipCode');
print('📤 [CREATE POSTCARD] Title: $pcTitle');
print('📤 [CREATE POSTCARD] Number: $pcNumber');
print('📤 [CREATE POSTCARD] Image File: ${pcImageFile.path}');
print('📤 [CREATE POSTCARD] Is Draft: $isDraft');
}
// ⭐ Create FormData for multipart/form-data upload
final formData = FormData();
// Add text fields
formData.fields.addAll([
MapEntry('countryName', countryName),
MapEntry('cityName', cityName),
MapEntry('stateName', stateName),
MapEntry('zipCode', zipCode),
MapEntry('pcTitle', pcTitle),
MapEntry('pcContent', pcContent),
MapEntry('pcNumber', pcNumber),
MapEntry('pcDatetime', pcDatetime),
MapEntry('fullname', fullname),
MapEntry('emailAddress', emailAddress),
MapEntry('mobileNumber', mobileNumber),
MapEntry('isdCode', isdCode),
MapEntry('isForSelf', isForSelf.toString()),
MapEntry('isDraft', 'true'),
MapEntry('isAddedToCart', 'true'),
]);
// Add optional address fields only if they are not null
if (address1 != null && address1.isNotEmpty) {
formData.fields.add(MapEntry('address1', address1));
}
if (address2 != null && address2.isNotEmpty) {
formData.fields.add(MapEntry('address2', address2));
}
// ⭐ Add postcard image file
final fileName = pcImageFile.path.split('/').last;
formData.files.add(
MapEntry(
'pcImage',
await MultipartFile.fromFile(
pcImageFile.path,
filename: fileName,
),
),
);
if (kDebugMode) {
print('📤 [CREATE POSTCARD] ✅ Postcard Image File Added');
print('📤 [CREATE POSTCARD] File Name: $fileName');
print('📤 [CREATE POSTCARD] File Path: ${pcImageFile.path}');
final fileSize = await pcImageFile.length();
print('📤 [CREATE POSTCARD] File Size: ${(fileSize / 1024).toStringAsFixed(2)} KB');
}
// ⭐ Log complete payload details
log('📦 Request Payload Summary:');
log('📦 Total Fields: ${formData.fields.length}');
log('📦 Total Files: ${formData.files.length}');
log('📦 Field Details:');
for (var field in formData.fields) {
log(' - ${field.key}: ${field.value}');
}
log('📦 File Details:');
for (var file in formData.files) {
log(' - ${file.key}: ${file.value.filename} (${file.value.length} bytes)');
}
log('🌐 API URL: ${ApiUrls.createPostCard}');
// ⭐ Send as multipart/form-data
final response = await _apiServices.postApi(
url: ApiUrls.createPostCard,
data: formData,
);
log('✅ API Response Status: ${response.statusCode}');
log('📥 API Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [CREATE POSTCARD] ✅ Response Status: Success');
print('📤 [CREATE POSTCARD] Full Response: ${response.data}');
}
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {
log(
'❌ createPostCard FAILED',
error: e,
stackTrace: stackTrace,
);
throw Exception('Failed to create postcard: $e');
}
}
/// 🆕 Confirm Payment after successful Stripe payment
/// POST https://devapi.citycards.betadelivery.com/mobile/postcards/{postcardId}/confirm-payment
Future<Map<String, dynamic>> confirmPayment({
required int postcardId,
required String stripeStatus,
required String paymentStatus,
}) async {
try {
log('🟢 confirmPayment() called');
log('📤 [CONFIRM PAYMENT] Postcard ID: $postcardId');
log('📤 [CONFIRM PAYMENT] Stripe Status: $stripeStatus');
log('📤 [CONFIRM PAYMENT] Payment Status: $paymentStatus');
// Construct URL with postcardId
final url = '${ApiUrls.baseUrl}/mobile/postcards/$postcardId/confirm-payment';
// Note: Update ApiUrls class if you want to use a constant instead
// final url = ApiUrls.confirmPayment(postcardId);
if (kDebugMode) {
print('📤 [CONFIRM PAYMENT] API URL: $url');
}
// Request body
final requestBody = {
'stripeStatus': stripeStatus,
'paymentStatus': paymentStatus,
};
log('📦 Request Body: $requestBody');
// Send POST request
final response = await _apiServices.postApi(
url: url,
data: requestBody,
);
log('✅ [CONFIRM PAYMENT] Response Status: ${response.statusCode}');
log('📥 [CONFIRM PAYMENT] Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [CONFIRM PAYMENT] ✅ Payment confirmation successful');
print('📤 [CONFIRM PAYMENT] Full Response: ${response.data}');
}
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {
log(
'❌ confirmPayment FAILED',
error: e,
stackTrace: stackTrace,
);
throw Exception('Failed to confirm payment: $e');
}
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
@@ -9,132 +10,44 @@ import '../../networkApiServices/network_api_services.dart';
class CreatePostCardRepository {
final NetworkApiService _apiServices = NetworkApiService();
/// Create / Save Postcard (Draft or Final)
/// ⭐ UPDATED: Now uses multipart/form-data for file upload
/// ============================================================
/// Create / Update Postcard (Draft or Final)
/// Uses multipart/form-data
/// URL requires pcId
/// ============================================================
/// ============================================================
/// Create / Update Postcard (Draft or Pay)
/// POST /mobile/postcards/{pcId}/draft-or-pay
/// Payload: { "isDraft": true/false }
/// ============================================================
Future<Map<String, dynamic>> createPostCard({
required String countryName,
required String cityName,
required String stateName,
required String zipCode,
String? address1, // NOT required
String? address2, // NOT required
required String pcTitle,
required String pcContent,
required File pcImageFile, // ⭐ CHANGED: File instead of String
required String pcNumber,
required String pcDatetime,
required String fullname,
required String emailAddress,
required String mobileNumber,
required String isdCode,
required bool isForSelf,
required int pcId,
required bool isDraft,
required double baseAmount,
required double totalTaxAmount,
required double totalAmount,
}) async {
try {
log('🟡 createPostCard() called');
log('🆔 Postcard ID: $pcId');
log('📝 isDraft: $isDraft');
if (kDebugMode) {
print('📤 [CREATE POSTCARD] Country: $countryName');
print('📤 [CREATE POSTCARD] City: $cityName');
print('📤 [CREATE POSTCARD] State: $stateName');
print('📤 [CREATE POSTCARD] Zip: $zipCode');
print('📤 [CREATE POSTCARD] Title: $pcTitle');
print('📤 [CREATE POSTCARD] Number: $pcNumber');
print('📤 [CREATE POSTCARD] Image File: ${pcImageFile.path}');
print('📤 [CREATE POSTCARD] Is Draft: $isDraft');
}
// ============================
// API Call
// ============================
final url = '${ApiUrls.baseUrl}/mobile/postcards/$pcId/draft-or-pay';
// ⭐ Create FormData for multipart/form-data upload
final formData = FormData();
final requestBody = {
'isDraft': isDraft,
};
// Add text fields
formData.fields.addAll([
MapEntry('countryName', countryName),
MapEntry('cityName', cityName),
MapEntry('stateName', stateName),
MapEntry('zipCode', zipCode),
MapEntry('pcTitle', pcTitle),
MapEntry('pcContent', pcContent),
MapEntry('pcNumber', pcNumber),
MapEntry('pcDatetime', pcDatetime),
MapEntry('fullname', fullname),
MapEntry('emailAddress', emailAddress),
MapEntry('mobileNumber', mobileNumber),
MapEntry('isdCode', isdCode),
MapEntry('isForSelf', isForSelf.toString()),
MapEntry('isDraft', isDraft.toString()),
MapEntry('baseAmount', baseAmount.toString()),
MapEntry('totalTaxAmount', totalTaxAmount.toString()),
MapEntry('totalAmount', totalAmount.toString()),
]);
log('🌐 API URL: $url');
log('📦 Request Body: $requestBody');
// Add optional address fields only if they are not null
if (address1 != null && address1.isNotEmpty) {
formData.fields.add(MapEntry('address1', address1));
}
if (address2 != null && address2.isNotEmpty) {
formData.fields.add(MapEntry('address2', address2));
}
// ⭐ Add postcard image file
final fileName = pcImageFile.path.split('/').last;
formData.files.add(
MapEntry(
'pcImage',
await MultipartFile.fromFile(
pcImageFile.path,
filename: fileName,
),
),
final response = await _apiServices.putApi(
url: url,
data: requestBody,
);
if (kDebugMode) {
print('📤 [CREATE POSTCARD] ✅ Postcard Image File Added');
print('📤 [CREATE POSTCARD] File Name: $fileName');
print('📤 [CREATE POSTCARD] File Path: ${pcImageFile.path}');
final fileSize = await pcImageFile.length();
print('📤 [CREATE POSTCARD] File Size: ${(fileSize / 1024).toStringAsFixed(2)} KB');
}
// ⭐ Log complete payload details
log('📦 Request Payload Summary:');
log('📦 Total Fields: ${formData.fields.length}');
log('📦 Total Files: ${formData.files.length}');
log('📦 Field Details:');
for (var field in formData.fields) {
log(' - ${field.key}: ${field.value}');
}
log('📦 File Details:');
for (var file in formData.files) {
log(' - ${file.key}: ${file.value.filename} (${file.value.length} bytes)');
}
log('🌐 API URL: ${ApiUrls.createPostCard}');
// ⭐ Send as multipart/form-data
final response = await _apiServices.postApi(
url: ApiUrls.createPostCard,
data: formData,
);
log('✅ API Response Status: ${response.statusCode}');
log('📥 API Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [CREATE POSTCARD] ✅ Response Status: Success');
print('📤 [CREATE POSTCARD] Full Response: ${response.data}');
}
log('✅ API Status: ${response.statusCode}');
log('📥 API Response: ${response.data}');
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {
@@ -147,8 +60,10 @@ class CreatePostCardRepository {
}
}
/// 🆕 Confirm Payment after successful Stripe payment
/// POST https://devapi.citycards.betadelivery.com/mobile/postcards/{postcardId}/confirm-payment
/// ============================================================
/// Confirm Stripe Payment
/// POST /mobile/postcards/{postcardId}/confirm-payment
/// ============================================================
Future<Map<String, dynamic>> confirmPayment({
required int postcardId,
required String stripeStatus,
@@ -156,41 +71,26 @@ class CreatePostCardRepository {
}) async {
try {
log('🟢 confirmPayment() called');
log('📤 [CONFIRM PAYMENT] Postcard ID: $postcardId');
log('📤 [CONFIRM PAYMENT] Stripe Status: $stripeStatus');
log('📤 [CONFIRM PAYMENT] Payment Status: $paymentStatus');
log('🆔 Postcard ID: $postcardId');
// Construct URL with postcardId
final url = '${ApiUrls.baseUrl}/mobile/postcards/$postcardId/confirm-payment';
final url =
'${ApiUrls.baseUrl}/mobile/postcards/$postcardId/confirm-payment';
// Note: Update ApiUrls class if you want to use a constant instead
// final url = ApiUrls.confirmPayment(postcardId);
if (kDebugMode) {
print('📤 [CONFIRM PAYMENT] API URL: $url');
}
// Request body
final requestBody = {
'stripeStatus': stripeStatus,
'paymentStatus': paymentStatus,
};
log('🌐 API URL: $url');
log('📦 Request Body: $requestBody');
// Send POST request
final response = await _apiServices.postApi(
url: url,
data: requestBody,
);
log('[CONFIRM PAYMENT] Response Status: ${response.statusCode}');
log('📥 [CONFIRM PAYMENT] Response Data: ${response.data}');
if (kDebugMode) {
print('📤 [CONFIRM PAYMENT] ✅ Payment confirmation successful');
print('📤 [CONFIRM PAYMENT] Full Response: ${response.data}');
}
log('Payment Confirmed: ${response.statusCode}');
log('📥 Response: ${response.data}');
return response.data as Map<String, dynamic>;
} catch (e, stackTrace) {

View File

@@ -31,7 +31,27 @@ class AddFilterStepPageView extends StatelessWidget {
children: [
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true),
StepProgressBar(totalSteps: 4, currentStep: 2),
const SizedBox(height: 24),
GestureDetector(
onTap: () {
context.read<PostcardCreationBloc>().add(GoToPreviousStep());
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
Icon(Icons.arrow_back, size: 20),
const SizedBox(width: 8),
Text(
"Back",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Text(
"Add a Filter",
style: TextStyle(

View File

@@ -351,6 +351,7 @@ class _EditPostcardViewState extends State<EditPostcardView> {
style: TextStyle(
color: Color(0xffF95F62),
fontWeight: FontWeight.w500,
fontSize: 12.sp,
),
),
SizedBox(width: icon != null ? 8 : 0),

View File

@@ -45,16 +45,24 @@ class _MyPostcardPreviewViewState extends State<MyPostcardPreviewView> {
Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"#${widget.postcard.pcNumber}",
/// PC Number (takes only available space)
Expanded(
child: Text(
widget.postcard.pcNumber,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.poppins(
color: Colors.black,
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
),
SizedBox(width: 12.w),
/// Action Icons
Row(
children: [
GestureDetector(
@@ -112,14 +120,14 @@ class _MyPostcardPreviewViewState extends State<MyPostcardPreviewView> {
children: [
Icon(
Icons.arrow_back,
color: !showBack ? const Color(0xffF95F62) : Colors.grey[400],
color: !showBack ? Colors.grey[400] : const Color(0xffF95F62),
size: 20,
),
SizedBox(width: 6.w),
Text(
'Flip',
style: GoogleFonts.poppins(
color: !showBack ? const Color(0xffF95F62) : Colors.grey[400],
color: !showBack ? Colors.grey[400] : const Color(0xffF95F62),
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
@@ -138,7 +146,7 @@ class _MyPostcardPreviewViewState extends State<MyPostcardPreviewView> {
Text(
'Flip',
style: GoogleFonts.poppins(
color: showBack ? const Color(0xffF95F62) : Colors.grey[400],
color: showBack ? Colors.grey[400] : const Color(0xffF95F62),
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
@@ -146,7 +154,7 @@ class _MyPostcardPreviewViewState extends State<MyPostcardPreviewView> {
SizedBox(width: 6.w),
Icon(
Icons.arrow_forward,
color: showBack ? const Color(0xffF95F62) : Colors.grey[400],
color: showBack ? Colors.grey[400] : const Color(0xffF95F62),
size: 20,
),
],

View File

@@ -58,7 +58,7 @@ class OrderSuccessPageView extends StatelessWidget {
text: "Your order has been placed. Your order\nid is ",
),
TextSpan(
text: "#${state.pcNumber ?? 'N/A'}", // 🆕 USE DYNAMIC VALUE
text: state.pcNumber ?? 'N/A', // 🆕 USE DYNAMIC VALUE
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xff585858),
@@ -86,9 +86,13 @@ class OrderSuccessPageView extends StatelessWidget {
angle: 0.20,
child: BackCardWidget(
message: state.message ?? "",
state: "State",
country: "country",
city: "City",
state: state.state??"",
country: state.country??"",
city: state.city??"",
selectedFont: state.selectedFont,
pincode: state.zipCode??"",
name: state.fullName??"",
address: state.address,
key: const ValueKey('back'),
// selectedFont: state.selectedFont,
),

View File

@@ -41,6 +41,7 @@ class PostcardCheckoutPageView extends StatefulWidget {
final double baseAmount;
final double totalTaxAmount;
final double totalAmount;
final int? postcardId;
const PostcardCheckoutPageView({
super.key,
@@ -61,6 +62,7 @@ class PostcardCheckoutPageView extends StatefulWidget {
required this.baseAmount,
required this.totalTaxAmount,
required this.totalAmount,
required this.postcardId,
});
@override
@@ -102,6 +104,7 @@ class _PostcardCheckoutPageViewState extends State<PostcardCheckoutPageView> {
baseAmount: widget.baseAmount,
totalTaxAmount: widget.totalTaxAmount,
totalAmount: widget.totalAmount,
postcardId: widget.postcardId,
),
);
});
@@ -302,13 +305,13 @@ class _PostcardCheckoutPageViewState extends State<PostcardCheckoutPageView> {
),
);
final bloc = context.read<PostcardCheckoutBloc>();
bloc.add(
ConfirmPaymentEvent(
stripeStatus: 'requires_payment_method',
paymentStatus: 'failed',
),
);
// final bloc = context.read<PostcardCheckoutBloc>();
// bloc.add(
// ConfirmPaymentEvent(
// stripeStatus: 'requires_payment_method',
// paymentStatus: 'failed',
// ),
// );
}
}
@@ -382,7 +385,27 @@ class _PostcardCheckoutPageViewState extends State<PostcardCheckoutPageView> {
isProfilePage: false,
showDivider: true,
),
// Header
GestureDetector(
onTap: () {
context.read<PostcardCreationBloc>().add(GoToPreviousStep());
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(Icons.arrow_back, size: 20),
const SizedBox(width: 8),
Text(
"Back",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -426,6 +449,7 @@ class _PostcardCheckoutPageViewState extends State<PostcardCheckoutPageView> {
address: creationState.address,
name: widget.fullname,
pincode: widget.zipCode,
selectedFont: creationState.selectedFont,
key: const ValueKey('back'),
// selectedFont: creationState.selectedFont,
),

View File

@@ -7,10 +7,12 @@ import 'package:citycards_customer/postcard/views/upload_photo_step_page_view.da
import 'package:citycards_customer/postcard/views/write_message_step_page_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/addToCartPostcard/add_to_cart_postcard_bloc.dart';
import '../blocs/addToCartPostcard/add_to_cart_postcard_state.dart';
import '../blocs/postcardCheckout/postcard_checkout_bloc.dart';
import '../blocs/postcard_creation_bloc.dart';
import '../blocs/postcard_creation_state.dart';
import '../repository/postcard_add_to_cart_repository.dart';
import '../repository/postcard_checkout_repository.dart';
import 'my_postcards_view.dart';
import 'order_success_page_view.dart';
@@ -20,8 +22,17 @@ class PostcardCreationPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
return MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => PostcardCreationBloc(),
),
BlocProvider(
create: (_) => AddToCartPostCardBloc(
AddToCartPostCardRepository(),
),
),
],
child: BlocBuilder<PostcardCreationBloc, PostcardCreationState>(
builder: (context, state) {
Widget stepWidget;
@@ -39,9 +50,40 @@ class PostcardCreationPage extends StatelessWidget {
stepWidget = const PreviewPostcardStepPageView();
break;
case PostcardStep.purchase:
stepWidget = const PostcardPurchaseFormPageView();
// If buying for myself (isGift = false), use user profile data
// Otherwise, leave fields empty for gift recipient
stepWidget = PostcardPurchaseFormPageView(
initialFullName: !state.isGift ? state.userProfileFullName : null,
initialEmail: !state.isGift ? state.userProfileEmail : null,
initialPhone: !state.isGift ? state.userProfilePhone : null,
initialAddress: !state.isGift ? state.userProfileAddress : null,
initialCity: !state.isGift ? state.userProfileCity : null,
initialState: !state.isGift ? state.userProfileState : null,
initialZipCode: !state.isGift ? state.userProfileZipCode : null,
initialCountry: !state.isGift ? state.userProfileCountry : null,
);
break;
case PostcardStep.checkout:
// Get the cart state to access response data
final cartState = context.read<AddToCartPostCardBloc>().state;
// Extract values from the cart response or use defaults
String pcNumber = '12';
String pcDatetime = '';
double baseAmount = 50;
double totalTaxAmount = 20;
double totalAmount = 30;
int? postcardId;
if (cartState is AddToCartPostCardSuccess) {
pcNumber = cartState.pcNumber;
pcDatetime = cartState.pcDatetime;
baseAmount = cartState.baseAmount;
totalTaxAmount = cartState.totalTaxAmount;
totalAmount = cartState.totalAmount;
postcardId = cartState.postcardId;
}
stepWidget = BlocProvider(
create: (_) => PostcardCheckoutBloc(
repository: CreatePostCardRepository(),
@@ -51,17 +93,20 @@ class PostcardCreationPage extends StatelessWidget {
cityName: state.city ?? 'N/A',
stateName: state.state ?? 'N/A',
zipCode: state.zipCode ?? 'N/A',
address1: state.address, // ✅ Add this
address2: '', // ✅ Add this (or pass actual value if you have it)
pcTitle: state.pcTitle ?? 'N/A',
pcNumber: '12',
pcDatetime: '2008-11-20',
pcNumber: pcNumber,
pcDatetime: pcDatetime,
fullname: state.fullName ?? 'N/A',
emailAddress: state.emailId ?? 'N/A',
mobileNumber: state.phoneNumber ?? 'N/A',
isdCode: '+91',
isForSelf: !state.isGift,
totalTaxAmount: 20,
baseAmount: 50,
totalAmount: 30,
totalTaxAmount: totalTaxAmount,
baseAmount: baseAmount,
totalAmount: totalAmount,
postcardId: postcardId,
),
);
break;

View File

@@ -3,13 +3,36 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../common_packages/app_bar.dart';
import '../blocs/addToCartPostcard/add_to_cart_postcard_bloc.dart';
import '../blocs/addToCartPostcard/add_to_cart_postcard_event.dart';
import '../blocs/addToCartPostcard/add_to_cart_postcard_state.dart';
import '../blocs/postcard_creation_bloc.dart';
import '../blocs/postcard_creation_events.dart';
import '../blocs/postcard_creation_state.dart';
class PostcardPurchaseFormPageView extends StatefulWidget {
const PostcardPurchaseFormPageView({super.key});
final String? initialFullName;
final String? initialEmail;
final String? initialPhone;
final String? initialAddress;
final String? initialCity;
final String? initialState;
final String? initialZipCode;
final String? initialCountry;
const PostcardPurchaseFormPageView({
super.key,
this.initialFullName,
this.initialEmail,
this.initialPhone,
this.initialAddress,
this.initialCity,
this.initialState,
this.initialZipCode,
this.initialCountry,
});
@override
State<PostcardPurchaseFormPageView> createState() => _PostcardPurchaseFormPageViewState();
@@ -30,6 +53,20 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
String? _selectedCountry;
String? _selectedState;
@override
void initState() {
super.initState();
// Initialize controllers with prefill values
_fullNameController.text = widget.initialFullName ?? '';
_emailController.text = widget.initialEmail ?? '';
_phoneController.text = widget.initialPhone ?? '';
_addressController.text = widget.initialAddress ?? '';
_cityController.text = widget.initialCity ?? '';
_zipCodeController.text = widget.initialZipCode ?? '';
_selectedState = widget.initialState;
_selectedCountry = widget.initialCountry;
}
@override
void dispose() {
_titleController.dispose();
@@ -46,9 +83,27 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
Widget build(BuildContext context) {
return BlocBuilder<PostcardCreationBloc, PostcardCreationState>(
builder: (context, state) {
final bloc = context.read<PostcardCreationBloc>();
final creationBloc = context.read<PostcardCreationBloc>();
return SafeArea(
return BlocListener<AddToCartPostCardBloc, AddToCartPostCardState>(
listener: (context, cartState) {
if (cartState is AddToCartPostCardSuccess) {
// Update the postcard number in creation bloc
creationBloc.add(UpdatePostcardNumber(cartState.pcNumber));
// Navigate to next step (checkout)
creationBloc.add(GoToNextStep());
} else if (cartState is AddToCartPostCardFailure) {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(cartState.message),
backgroundColor: Colors.red,
),
);
}
},
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
@@ -61,7 +116,27 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
isProfilePage: false,
showDivider: true,
),
const SizedBox(height: 20),
GestureDetector(
onTap: () {
context.read<PostcardCreationBloc>().add(GoToPreviousStep());
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
Icon(Icons.arrow_back, size: 20),
const SizedBox(width: 8),
Text(
"Back",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Row(
children: [
ClipRRect(
@@ -176,29 +251,56 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
});
},
),
const SizedBox(height: 30),
// Next Button
SizedBox(
const SizedBox(height: 24),
// Next button
BlocBuilder<AddToCartPostCardBloc, AddToCartPostCardState>(
builder: (context, cartState) {
final isLoading = cartState is AddToCartPostCardLoading;
final addToCartBloc = context.read<AddToCartPostCardBloc>();
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Update the bloc with form data
bloc.add(UpdatePurchaseFormData(
onPressed: isLoading
? null
: () {
creationBloc.add(
UpdatePurchaseFormData(
pcTitle: _titleController.text,
fullName: _fullNameController.text,
emailId: _emailController.text,
phoneNumber: _phoneController.text,
address: _addressController.text,
city: _cityController.text,
country: _selectedCountry ?? '',
state: _selectedState ?? '',
state: _selectedState,
zipCode: _zipCodeController.text,
));
country: _selectedCountry,
),
);
if (_formKey.currentState!.validate()) {
final currentDate = DateFormat('yyyy-MM-dd').format(DateTime.now());
// Navigate to next step
bloc.add(GoToNextStep());
addToCartBloc.add(
AddToCartPostCardRequested(
countryName: _selectedCountry ?? '',
cityName: _cityController.text,
stateName: _selectedState ?? '',
zipCode: _zipCodeController.text,
address1: _addressController.text,
address2: null,
pcTitle: _titleController.text,
pcContent: creationBloc.getFormattedMessage(),
pcImageFile: File(state.imagePath!),
pcNumber: '12',
pcDatetime: currentDate,
fullname: _fullNameController.text,
emailAddress: _emailController.text,
mobileNumber: _phoneController.text,
isdCode: '+91',
),
);
}
},
style: ElevatedButton.styleFrom(
@@ -208,7 +310,16 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
borderRadius: BorderRadius.circular(40),
),
),
child: Text(
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
"Next",
style: TextStyle(
color: Colors.white,
@@ -217,11 +328,14 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
),
),
),
);
},
),
],
),
),
),
),
);
},
);
@@ -347,9 +461,23 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
fontSize: 14.sp,
),
),
items: const [
items: label == "Country"
? const [
DropdownMenuItem(value: "Australia", child: Text("Australia")),
]
: label == "State"
? const [
DropdownMenuItem(value: "New South Wales", child: Text("New South Wales")),
DropdownMenuItem(value: "Victoria", child: Text("Victoria")),
DropdownMenuItem(value: "Queensland", child: Text("Queensland")),
DropdownMenuItem(value: "South Australia", child: Text("South Australia")),
DropdownMenuItem(value: "Western Australia", child: Text("Western Australia")),
DropdownMenuItem(value: "Tasmania", child: Text("Tasmania")),
DropdownMenuItem(value: "Northern Territory", child: Text("Northern Territory")),
DropdownMenuItem(value: "Australian Capital Territory", child: Text("Australian Capital Territory")),
]
: const [
DropdownMenuItem(value: "Lorem Ipsum", child: Text("Lorem Ipsum")),
// Add more items as needed
],
onChanged: onChanged,
validator: (value) {

View File

@@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart';
import '../../common_packages/app_bar.dart';
import '../blocs/postcard_creation_bloc.dart';
import '../blocs/postcard_creation_events.dart';
import '../blocs/postcard_creation_state.dart';
import '../widgets/back_card_widget.dart';
import '../widgets/front_card_widget.dart';
@@ -38,7 +39,27 @@ class _PreviewPostcardStepPageViewState extends State<PreviewPostcardStepPageVie
children: [
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,),
StepProgressBar(totalSteps: 4, currentStep: 4),
const SizedBox(height: 24),
GestureDetector(
onTap: () {
context.read<PostcardCreationBloc>().add(GoToPreviousStep());
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
Icon(Icons.arrow_back, size: 20),
const SizedBox(width: 8),
Text(
"Back",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Text(
"Preview your Postcard",
@@ -103,6 +124,7 @@ class _PreviewPostcardStepPageViewState extends State<PreviewPostcardStepPageVie
city: state.city??"",
country: state.country??"",
state:state.state??"",
selectedFont: state.selectedFont,
// selectedFont: state.selectedFont,
):
FrontCardWidget(

View File

@@ -45,30 +45,13 @@ class _WriteMessageStepPageViewState extends State<WriteMessageStepPageView> {
final bloc = context.read<PostcardCreationBloc>();
_controller.text = state.message ?? "";
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
TextPosition(offset: _controller.text.length));
final fonts = [
{
"name": "Default",
"font": GoogleFonts.poppins(),
"cleanName": "Poppins",
},
{
"name": "Patrick Hand",
"font": GoogleFonts.patrickHand(),
"cleanName": "Patrick Hand",
},
{
"name": "Indie Flower",
"font": GoogleFonts.indieFlower(),
"cleanName": "Indie Flower",
},
{
"name": "Gloria Hallelujah",
"font": GoogleFonts.gloriaHallelujah(),
"cleanName": "Gloria Hallelujah",
},
{"name": "Default", "font": GoogleFonts.poppins(), "cleanName": "Poppins"},
{"name": "Patrick Hand", "font": GoogleFonts.patrickHand(), "cleanName": "Patrick Hand"},
{"name": "Indie Flower", "font": GoogleFonts.indieFlower(), "cleanName": "Indie Flower"},
{"name": "Gloria Hallelujah", "font": GoogleFonts.gloriaHallelujah(), "cleanName": "Gloria Hallelujah"},
];
return SafeArea(
@@ -77,17 +60,32 @@ class _WriteMessageStepPageViewState extends State<WriteMessageStepPageView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,),
StepProgressBar(totalSteps: 4, currentStep: 3),
const SizedBox(height: 24),
GestureDetector(
onTap: () {
context.read<PostcardCreationBloc>().add(GoToPreviousStep());
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
Icon(Icons.arrow_back, size: 20),
const SizedBox(width: 8),
Text(
"Write a message",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
"Back",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
Text("Write a message",
style:
TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text(
"Design your own unique postcards to cherish your unforgettable moments.",
@@ -155,8 +153,7 @@ class _WriteMessageStepPageViewState extends State<WriteMessageStepPageView> {
final String fontName = font["name"] as String;
final String cleanName = font["cleanName"] as String;
final isSelected =
state.selectedFont == cleanName ||
final isSelected = state.selectedFont == cleanName ||
(state.selectedFont == null && fontName == "Default");
return GestureDetector(
@@ -183,24 +180,20 @@ class _WriteMessageStepPageViewState extends State<WriteMessageStepPageView> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Aa",
Text("Aa",
style: fontStyle.copyWith(
fontSize: 24.sp,
color: const Color(0xff1A1A1A),
),
),
)),
const SizedBox(height: 4),
Text(
fontName,
Text(fontName,
textAlign: TextAlign.center,
style: fontStyle.copyWith(
fontSize: 11.sp,
color: isSelected
? const Color(0xffF95F62)
: const Color(0xff2D3134),
),
),
)),
],
),
),
@@ -245,10 +238,7 @@ class _WriteMessageStepPageViewState extends State<WriteMessageStepPageView> {
}
// Helper method to get the correct font style for the text field
TextStyle _getTextFieldStyle(
String? selectedFont,
List<Map<String, dynamic>> fonts,
) {
TextStyle _getTextFieldStyle(String? selectedFont, List<Map<String, dynamic>> fonts) {
if (selectedFont == null || selectedFont.isEmpty) {
return GoogleFonts.poppins(fontSize: 14.sp, color: Colors.black);
}
@@ -290,12 +280,10 @@ class DottedBorderPainter extends CustomPainter {
..style = PaintingStyle.stroke;
final path = Path()
..addRRect(
RRect.fromRectAndRadius(
..addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
Radius.circular(borderRadius),
),
);
));
// Create dashed path
final dashPath = _createDashedPath(path, dashWidth, dashSpace);
@@ -351,7 +339,11 @@ class LinedPaperPainter extends CustomPainter {
const lineSpacing = 30.0;
for (double i = lineSpacing; i < size.height; i += lineSpacing) {
canvas.drawLine(Offset(0, i), Offset(size.width, i), paint);
canvas.drawLine(
Offset(0, i),
Offset(size.width, i),
paint,
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:html/parser.dart' as html_parser;
class BackCardWidget extends StatelessWidget {
final String message;
final String? selectedFont;
final String name;
final String address;
final String city;
@@ -17,6 +18,7 @@ class BackCardWidget extends StatelessWidget {
const BackCardWidget({
super.key,
this.message = '',
this.selectedFont,
this.name = '',
this.address = '',
this.city = '',
@@ -98,6 +100,14 @@ class BackCardWidget extends StatelessWidget {
final messageText = parsedMessage['text'] ?? '';
final fontFamily = parsedMessage['fontFamily'] ?? '';
// Determine which font to use: selectedFont takes priority, then parsed fontFamily, then default
String finalFontFamily = '';
if (selectedFont != null && selectedFont!.isNotEmpty) {
finalFontFamily = selectedFont!;
} else if (fontFamily.isNotEmpty) {
finalFontFamily = fontFamily;
}
return Transform.scale(
scale: scale,
child: Container(
@@ -129,7 +139,7 @@ class BackCardWidget extends StatelessWidget {
child: SingleChildScrollView(
child: Text(
messageText,
style: _getFontStyle(fontFamily, 16.sp, 1.7),
style: _getFontStyle(finalFontFamily, 16.sp, 1.7),
),
),
),
@@ -192,28 +202,28 @@ class BackCardWidget extends StatelessWidget {
SizedBox(height: 5.h),
if (name.isNotEmpty) ...[
_addressLine(name),
_divider(),
],
_divider(),
if (address.isNotEmpty) ...[
_addressLine(address),
_divider(),
],
_divider(),
if (city.isNotEmpty) ...[
_addressLine(city),
_divider(),
],
if (state.isNotEmpty) ...[
_addressLine(state),
_divider(),
],
_divider(),
if (country.isNotEmpty) ...[
_addressLine(country),
_divider(),
],
_divider(),
if (pincode.isNotEmpty) ...[
_addressLine(pincode),
_divider(),
],
_divider(),
],
),
),

View File

@@ -33,6 +33,21 @@ class _EditYourdetailsState extends State<EditYourdetails> {
String? _selectedState;
String? _selectedCountry;
final List<String> countries = [
'Australia',
];
final List<String> states = [
'New South Wales',
'Victoria',
'Queensland',
'South Australia',
'Western Australia',
'Tasmania',
'Northern Territory',
'Australian Capital Territory',
];
@override
void initState() {
setState(() {
@@ -46,6 +61,7 @@ class _EditYourdetailsState extends State<EditYourdetails> {
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Recipient Details",
@@ -81,15 +97,28 @@ class _EditYourdetailsState extends State<EditYourdetails> {
hint: "Enter the name of your city",
controller: widget.cityController,
),
_buildDropdownField(
label: "Country",
hint: "Select your country",
value: _selectedCountry,
items: countries,
onChanged: (val) {
setState(() {
_selectedCountry = val;
});
widget.selectCountry(val!);
},
),
_buildDropdownField(
label: "State",
hint: "Select your state",
value: _selectedState,
items: states,
onChanged: (val) {
setState(() {
_selectedState = val;
});
widget.selectState;
widget.selectState(val!);
},
),
_buildInputField(
@@ -98,17 +127,6 @@ class _EditYourdetailsState extends State<EditYourdetails> {
controller: widget.zipCodeController,
keyboardType: TextInputType.number,
),
_buildDropdownField(
label: "Country",
hint: "Select your country",
value: _selectedCountry,
onChanged: (val) {
setState(() {
_selectedCountry = val;
});
widget.selectCountry;
},
),
],
);
}
@@ -182,11 +200,11 @@ class _EditYourdetailsState extends State<EditYourdetails> {
);
}
/// 🔹 Dropdown input
Widget _buildDropdownField({
required String label,
required String hint,
required String? value,
required List<String> items,
required Function(String?) onChanged,
}) {
return Padding(
@@ -238,13 +256,12 @@ class _EditYourdetailsState extends State<EditYourdetails> {
fontSize: 14.sp,
),
),
items: const [
DropdownMenuItem(
value: "Lorem Ipsum",
child: Text("Lorem Ipsum"),
),
// Add more items as needed
],
items: items.map((String item) {
return DropdownMenuItem<String>(
value: item,
child: Text(item),
);
}).toList(),
onChanged: onChanged,
validator: (value) {
if (value == null || value.isEmpty) {

View File

@@ -219,6 +219,21 @@ class PurchaseDetailsBottomSheet {
width: double.infinity,
child: ElevatedButton(
onPressed: () {
// If buying for myself, store the profile data
if (!postcardState.isGift && purchaseState.profile != null) {
final profile = purchaseState.profile!;
postcardBloc.add(StoreUserProfileData(
fullName: "${profile.firstName ?? ''} ${profile.lastName ?? ''}".trim(),
email: profile.emailAddress,
phone: profile.mobileNumber,
address: "${profile.address1 ?? ''} ${profile.address2 ?? ''}".trim(),
city: profile.cityName,
state: profile.stateName,
zipCode: profile.zipCode,
country: profile.country,
));
}
PurchaseDetailsBottomSheet.close(context);
postcardBloc.add(GoToNextStep());
},

View File

@@ -26,7 +26,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
Emitter<ProfileState> emit,
) async {
try {
emit(const ProfileLoading());
final profile = await _profileRepository.fetchUserProfile();
@@ -54,6 +53,12 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
print('📄 [BLOC] Address1: ${event.address1}');
print('📄 [BLOC] Address2: ${event.address2}');
// ⭐ NEW DEBUG LOGS
print('📄 [BLOC] City: ${event.city}');
print('📄 [BLOC] State: ${event.state}');
print('📄 [BLOC] Country: ${event.country}');
print('📄 [BLOC] Postal Code: ${event.postalCode}');
if (event.profileImageFile != null) {
print('📄 [BLOC] ✅ Profile Image File Present in Event');
print('📄 [BLOC] File Path: ${event.profileImageFile!.path}');

View File

@@ -18,6 +18,7 @@ class FetchProfileEvent extends ProfileEvent {
List<Object?> get props => [userId];
}
/// Event to update user profile
/// Event to update user profile
class UpdateProfileEvent extends ProfileEvent {
final int userId;
@@ -26,6 +27,10 @@ class UpdateProfileEvent extends ProfileEvent {
final String mobileNumber;
final String? address1;
final String? address2;
final String? city; // ⭐ NEW
final String? state; // ⭐ NEW
final String? country; // ⭐ NEW
final String? postalCode; // ⭐ NEW
final File? profileImageFile;
const UpdateProfileEvent({
@@ -35,6 +40,10 @@ class UpdateProfileEvent extends ProfileEvent {
required this.mobileNumber,
this.address1,
this.address2,
this.city, // ⭐ NEW
this.state, // ⭐ NEW
this.country, // ⭐ NEW
this.postalCode, // ⭐ NEW
this.profileImageFile,
});
@@ -46,6 +55,10 @@ class UpdateProfileEvent extends ProfileEvent {
mobileNumber,
address1,
address2,
city, // ⭐ NEW
state, // ⭐ NEW
country, // ⭐ NEW
postalCode, // ⭐ NEW
profileImageFile,
];
@@ -56,6 +69,10 @@ class UpdateProfileEvent extends ProfileEvent {
'mobileNumber': mobileNumber,
if (address1 != null && address1!.isNotEmpty) 'address1': address1,
if (address2 != null && address2!.isNotEmpty) 'address2': address2,
if (city != null && city!.isNotEmpty) 'city': city, // ⭐ NEW
if (state != null && state!.isNotEmpty) 'state': state, // ⭐ NEW
if (country != null && country!.isNotEmpty) 'country': country, // ⭐ NEW
if (postalCode != null && postalCode!.isNotEmpty) 'postalCode': postalCode, // ⭐ NEW
};
}
}

View File

@@ -1,6 +1,7 @@
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';
@@ -9,7 +10,7 @@ import '../../localPreference/local_preference.dart';
class ProfileRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Fetch user profile (userId from local storage)
/// Fetch user profile (userId from local storage)
Future<ProfileModel> fetchUserProfile() async {
final int? userId = await LocalPreference.getUserId();
@@ -20,11 +21,10 @@ class ProfileRepository {
return ProfileModel.fromJson(response.data);
}
/// Update user profile (userId from local storage)
/// ⭐ FIXED: Now uses multipart/form-data for file upload
/// Update user profile (Multipart with Image + New Address Fields)
Future<ProfileModel> updateUserProfile({
required Map<String, dynamic> data,
File? profileImageFile, // ⭐ NEW: Accept File instead of base64
File? profileImageFile,
}) async {
final int? userId = await LocalPreference.getUserId();
@@ -32,31 +32,56 @@ class ProfileRepository {
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}');
print('📤 First Name: ${data['firstName']}');
print('📤 Last Name: ${data['lastName']}');
print('📤 Mobile: ${data['mobileNumber']}');
print('📤 Address1: ${data['address1']}');
print('📤 Address2: ${data['address2']}');
// ⭐ NEW DEBUG LOGS
print('📤 City: ${data['city']}');
print('📤 State: ${data['state']}');
print('📤 Country: ${data['country']}');
print('📤 Postal Code: ${data['postalCode']}');
print('📤 Profile Image File: ${profileImageFile?.path}');
}
// Create FormData for multipart/form-data upload
/// ✅ Create FormData (Multipart)
final formData = FormData();
// Add text fields
/// ✅ 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']),
/// ⭐ NEW FIELDS
if (data['city'] != null && data['city'].toString().isNotEmpty)
MapEntry('city', data['city']),
if (data['state'] != null && data['state'].toString().isNotEmpty)
MapEntry('state', data['state']),
if (data['country'] != null && data['country'].toString().isNotEmpty)
MapEntry('country', data['country']),
if (data['postalCode'] != null &&
data['postalCode'].toString().isNotEmpty)
MapEntry('postalCode', data['postalCode']),
]);
// Add profile image file if provided
/// ✅ Add Profile Image File
if (profileImageFile != null) {
final fileName = profileImageFile.path.split('/').last;
formData.files.add(
MapEntry(
'profileImage',
@@ -68,46 +93,36 @@ class ProfileRepository {
);
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');
print('📤 ✅ Profile Image Added');
print('📤 File Name: $fileName');
print(
'📤 File Size: ${(fileSize / 1024).toStringAsFixed(2)} KB');
}
} else {
if (kDebugMode) {
print('📤 [UPDATE PROFILE] ⚠️ No profile image file provided');
print('📤 ⚠️ No profile image provided');
}
}
// ⭐ Send as multipart/form-data
/// ✅ API Call (Multipart PUT)
final response = await _apiService.putApi(
url: '${ApiUrls.userProfile}/$userId',
data: formData,
);
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']}');
}
print('📤 ✅ Response Success');
print('📤 Full Response: ${response.data}');
}
// Extract user data from nested response
/// ✅ Handle Nested Response (user object)
final userData = response.data.containsKey('user')
? response.data['user']
: response.data;
if (kDebugMode) {
print('📤 [UPDATE PROFILE] Parsing ProfileModel from: $userData');
print('📤 Parsing ProfileModel from: $userData');
}
return ProfileModel.fromJson(userData);

View File

@@ -30,6 +30,12 @@ class _EditProfilePageState extends State<EditProfilePage> {
final TextEditingController phoneController = TextEditingController();
final TextEditingController address1Controller = TextEditingController();
final TextEditingController address2Controller = TextEditingController();
final TextEditingController cityController = TextEditingController();
final TextEditingController zipCodeController = TextEditingController();
// Dropdown values
String? selectedState;
String? selectedCountry;
final _formKey = GlobalKey<FormState>();
final ImagePicker _picker = ImagePicker();
@@ -64,6 +70,14 @@ class _EditProfilePageState extends State<EditProfilePage> {
phoneController.text = profile.mobileNumber;
address1Controller.text = profile.address1 ?? '';
address2Controller.text = profile.address2 ?? '';
cityController.text = profile.cityName ?? '';
zipCodeController.text = profile.zipCode ?? '';
// Set dropdown values from fetched data
setState(() {
selectedState = profile.stateName;
selectedCountry = profile.country;
});
// ⭐ REMOVED setState - image is now managed by BLoC state
if (kDebugMode && profile.profileImage != null && profile.profileImage!.isNotEmpty) {
@@ -321,6 +335,15 @@ class _EditProfilePageState extends State<EditProfilePage> {
address2: address2Controller.text.trim().isEmpty
? null
: address2Controller.text.trim(),
// ⭐ UPDATED: Use dropdown values instead of controllers
city: cityController.text.trim().isEmpty
? null
: cityController.text.trim(),
state: selectedState,
country: selectedCountry,
postalCode: zipCodeController.text.trim().isEmpty
? null
: zipCodeController.text.trim(),
profileImageFile: imageFileToSend,
),
);
@@ -333,6 +356,8 @@ class _EditProfilePageState extends State<EditProfilePage> {
phoneController.dispose();
address1Controller.dispose();
address2Controller.dispose();
cityController.dispose();
zipCodeController.dispose();
super.dispose();
}
@@ -495,7 +520,7 @@ class _EditProfilePageState extends State<EditProfilePage> {
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
child: CustomTextField(
label: "Address 1",
label: "Address",
hint: "Enter address manually or tap to search",
controller: address1Controller,
enabled: !isLoading,
@@ -512,6 +537,151 @@ class _EditProfilePageState extends State<EditProfilePage> {
),
),
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "State", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedState,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select state",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: isLoading ? null : (value) {
setState(() {
selectedState = value;
});
},
items: [
"New South Wales",
"Victoria",
"Queensland",
"South Australia",
"Western Australia",
"Tasmania",
"Northern Territory",
"Australian Capital Territory"
].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Country", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountry,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select country",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: isLoading ? null : (value) {
setState(() {
selectedCountry = value;
});
},
items: ["Australia"].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
child: CustomTextField(
label: "City",
hint: "Enter the name of your city",
controller: cityController,
enabled: !isLoading,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
child: CustomTextField(
label: "ZIP Code",
hint: "Enter the ZIP code you reside in",
controller: zipCodeController,
enabled: !isLoading,
),
),
SizedBox(height: 26.h),
// Buttons

View File

@@ -73,7 +73,7 @@ class Offer {
factory Offer.fromJson(Map<String, dynamic> json) {
return Offer(
id: json['id'],
id: json['id'] ?? 0,
title: json['title'] ?? '',
description: json['description'] ?? '',
offerCode: json['offerCode'] ?? '',
@@ -133,7 +133,7 @@ class City {
factory City.fromJson(Map<String, dynamic> json) {
return City(
id: json['id'],
id: json['id'] ?? 0,
name: json['name'] ?? '',
);
}
@@ -151,8 +151,8 @@ class City {
class CardInfo {
final int id;
final String title;
final int adultPrice;
final int childPrice;
final num adultPrice;
final num childPrice;
CardInfo({
required this.id,
@@ -163,7 +163,7 @@ class CardInfo {
factory CardInfo.fromJson(Map<String, dynamic> json) {
return CardInfo(
id: json['id'],
id: json['id'] ?? 0,
title: json['title'] ?? '',
adultPrice: json['adultPrice'] ?? 0,
childPrice: json['childPrice'] ?? 0,
@@ -193,7 +193,7 @@ class CardType {
factory CardType.fromJson(Map<String, dynamic> json) {
return CardType(
id: json['id'],
id: json['id'] ?? 0,
displayName: json['displayName'] ?? '',
);
}
@@ -219,7 +219,7 @@ class Category {
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'],
id: json['id'] ?? 0,
categoryName: json['categoryName'] ?? '',
);
}

Some files were not shown because too many files have changed in this diff Show More