Files
CityCards_Customer_Flutter/lib/checkout/view/checkout_view.dart
2026-02-13 15:27:14 +05:30

817 lines
34 KiB
Dart

import 'package:citycards_customer/checkout/bloc/checkOut/checkout_bloc.dart';
import 'package:citycards_customer/checkout/bloc/checkOut/checkout_event.dart';
import 'package:citycards_customer/checkout/bloc/checkOut/checkout_state.dart';
import 'package:citycards_customer/checkout/widget/all_coupons_bottomsheet.dart';
import 'package:citycards_customer/login/view/login_email_bottomsheet.dart';
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:citycards_customer/common_packages/custom_dashed_line.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
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';
import '../models/all_coupons_model.dart';
class CheckoutView extends StatefulWidget {
final int bookingId;
const CheckoutView({super.key, required this.bookingId});
@override
State<CheckoutView> createState() => _CheckoutViewState();
}
class _CheckoutViewState extends State<CheckoutView> {
bool isPurchaseDetailsConfirmed = false;
@override
Widget build(BuildContext context) {
// ✅ Receive checkout data from navigation arguments
final arguments = ModalRoute.of(context)?.settings.arguments;
CheckoutData? checkoutData;
if (arguments is CheckoutData) {
checkoutData = arguments;
print("✅ CHECKOUT DATA RECEIVED!");
print(" City: ${checkoutData.cityName}");
print(" Adults: ${checkoutData.adultCount}");
print(" Children: ${checkoutData.childCount}");
print(" Total: \$${checkoutData.totalPrice}");
} else {
print("❌ NO CHECKOUT DATA - showing error screen");
}
// ✅ If no data passed, show error or default values
if (checkoutData == null) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
SizedBox(height: 16.h),
CustomText(
text: "No checkout data available",
size: 16.sp,
color: Colors.red,
),
SizedBox(height: 8.h),
CustomText(
text: "Arguments type: ${arguments.runtimeType}",
size: 12.sp,
color: Colors.grey,
),
SizedBox(height: 20.h),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text("Go Back"),
),
],
),
),
),
);
}
return BlocProvider(
create: (context) => CheckoutBloc(
couponsRepository: AllCouponsRepository(),
checkoutRepository: CheckoutRepository(),
)..add(FetchCheckoutCouponsEvent()),
child: _CheckoutContent(
checkoutData: checkoutData,
bookingId: widget.bookingId,
isPurchaseDetailsConfirmed: isPurchaseDetailsConfirmed,
onPurchaseDetailsChanged: (value) {
setState(() {
isPurchaseDetailsConfirmed = value;
});
},
),
);
}
}
class _CheckoutContent extends StatefulWidget {
final CheckoutData checkoutData;
final int bookingId;
final bool isPurchaseDetailsConfirmed;
final Function(bool) onPurchaseDetailsChanged;
const _CheckoutContent({
required this.checkoutData,
required this.bookingId,
required this.isPurchaseDetailsConfirmed,
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 {
final paymentSuccess = await StripePaymentScreen.showAsBottomSheet(
context: context,
clientSecret: clientSecret,
amount: finalTotal,
currencySymbol: '\$',
title: 'Complete Payment',
loadingMessage: 'Processing your pass payment...',
successMessage: 'Payment Successful!\nYour pass is ready.',
failureMessage: 'Payment Failed',
primaryColor: const Color(0xFFF95F62),
heightRatio: 0.5,
isDismissible: false,
enableDrag: false,
onPaymentSuccess: () {
context.read<CheckoutBloc>().add(
ConfirmPaymentEvent(
bookingId: bookingId,
stripeStatus: 'succeeded',
paymentStatus: 'success',
),
);
},
onPaymentFailure: (error) {
context.read<CheckoutBloc>().add(
ConfirmPaymentEvent(
bookingId: bookingId,
stripeStatus: 'failed',
paymentStatus: 'failed',
),
);
},
onPaymentCancelled: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Payment cancelled'),
backgroundColor: Colors.orange,
),
);
},
);
// ✅ USE paymentSuccess HERE
if (paymentSuccess == true && context.mounted) {
// Wait a moment for backend confirmation
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!'),
backgroundColor: Colors.green,
),
);
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<CheckoutBloc, CheckoutState>(
listener: (context, state) {
// 🆕 Listen for payment initiation success
if (state is CheckoutCouponsLoadedState) {
// 🔒 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 = widget.checkoutData.totalPrice; // Changed to widget.
final double discountAmount = subtotal * (discountPercentage / 100);
final double totalBeforeTax = subtotal - discountAmount;
final double taxAmount = 2;
final double finalTotal = totalBeforeTax + taxAmount;
// ✅ Trigger payment flow with finalTotal
WidgetsBinding.instance.addPostFrameCallback((_) {
_handlePaymentFlow(
context,
state.clientSecret!,
state.bookingId ?? widget.bookingId,
finalTotal, // ✅ Pass the calculated finalTotal
);
});
}
// 🆕 Listen for payment confirmation success
if (state.isPaymentConfirmed) {
// Navigate to success page or back
Future.delayed(const Duration(seconds: 2), () {
if (context.mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
});
}
// 🆕 Listen for payment confirmation error
if (state.confirmationError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.confirmationError!),
backgroundColor: Colors.red,
),
);
}
}
// 🆕 Handle payment initiation error
if (state is CheckoutPaymentInitiationErrorState) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: Colors.red,
),
);
}
// 🆕 Handle payment confirmation error
if (state is CheckoutPaymentConfirmationErrorState) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
// ✅ Calculate pricing
double discountPercentage = 0.0;
AllCouponsModel? appliedCoupon;
bool isInitiatingPayment = false;
bool isConfirmingPayment = false;
if (state is CheckoutCouponsLoadedState) {
appliedCoupon = state.appliedCoupon;
if (appliedCoupon != null) {
discountPercentage = appliedCoupon.discountPercent.toDouble();
}
isInitiatingPayment = state.isInitiatingPayment;
isConfirmingPayment = state.isConfirmingPayment;
}
final num subtotal = widget.checkoutData.totalPrice;
final double discountAmount = subtotal * (discountPercentage / 100);
// final double taxRate = 0.05; // 5% tax
final double totalBeforeTax = subtotal - discountAmount;
// final double taxAmount = totalBeforeTax * taxRate;
final double taxAmount = 2;
final double finalTotal = totalBeforeTax + taxAmount;
return Scaffold(
resizeToAvoidBottomInset: true,
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
// ✅ App Bar
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showCart: false,
showDivider: true,
),
// ✅ Back Button & Title
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.arrow_back),
),
SizedBox(width: 8.w),
CustomText(text: "Checkout", size: 12.sp),
],
),
SizedBox(height: 22.h),
// ✅ PASS CARD SECTION
Container(
height: 140.h,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: widget.checkoutData.themeColor.withOpacity(0.2),
),
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
// ✅ Hero Image
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
child: widget.checkoutData.heroImage.isNotEmpty
? Image.network(
widget.checkoutData.heroImage,
width: 105.w,
height: 140.h,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _fallbackImage();
},
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: 105.w,
height: 140.h,
color: Colors.grey[200],
child: Center(
child: SizedBox(
width: 24.w,
height: 24.w,
child: CircularProgressIndicator(
strokeWidth: 2,
color: widget.checkoutData.themeColor,
),
),
),
);
},
)
: _fallbackImage(),
),
SizedBox(width: 6.66.w),
// ✅ Pass Details
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// City Name
CustomText(
text: widget.checkoutData.cityName,
weight: FontWeight.w500,
size: 16.sp,
),
SizedBox(height: 5.h),
// Validity (Days or Attractions)
CustomText(
text: widget.checkoutData.validityLabel,
color: const Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(height: 5.h),
// Adults
SizedBox(
width: MediaQuery.of(context).size.width * .5,
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
// Adults
if (widget.checkoutData.adultCount > 0)
Row(
children: [
Image.asset(
'assets/icons/adult.png',
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text:
"${widget.checkoutData.adultCount} adult${widget.checkoutData.adultCount > 1 ? 's' : ''}",
color: const Color(0xFF8E8E8E),
size: 12.sp,
),
],
),
],
),
),
SizedBox(height: 5.h),
Row(
children: [
// Children
if (widget.checkoutData.childCount > 0) ...[
Image.asset(
"assets/icons/kid.png",
scale: 4,
),
SizedBox(width: 4.w),
CustomText(
text:
"${widget.checkoutData.childCount} Kid${widget.checkoutData.childCount > 1 ? 's' : ''}",
color: const Color(0xFF8E8E8E),
size: 12.sp,
),
SizedBox(width: 53.w),
] else
SizedBox(width: 120.w),
// Total Price
CustomText(
text: "\$${subtotal.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
color: widget.checkoutData.themeColor,
),
],
),
],
),
],
),
// ✅ Card Type Label (Vertical)
Container(
width: 35.w,
height: 140.h,
decoration: BoxDecoration(
color: widget.checkoutData.themeColor,
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(8.r),
topRight: Radius.circular(8.r),
),
),
child: RotatedBox(
quarterTurns: -1,
child: Center(
child: Text(
widget.checkoutData.cardDisplayName,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
),
SizedBox(height: 20.h),
// ✅ COUPON SECTION
Container(
width: double.infinity,
padding:
EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
decoration: BoxDecoration(
color: const Color(0xFFF95F62).withOpacity(0.06),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xFFF95F62),
width: 0.3,
),
),
child: state is CheckoutCouponsLoadingState
? Row(
children: [
SizedBox(
width: 16.w,
height: 16.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFFF95F62),
),
),
SizedBox(width: 8.w),
CustomText(
text: "Loading coupons...",
size: 12.sp,
color: Colors.grey,
),
],
)
: state is CheckoutCouponsErrorState
? CustomText(
text: "Error loading coupons",
size: 12.sp,
color: Colors.red,
)
: state is CheckoutCouponsLoadedState
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// LEFT CONTENT
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: appliedCoupon != null
? "Coupon Applied: ${appliedCoupon.couponCode}"
: state.coupons.isNotEmpty
? "${state.coupons[0].discountPercent}% discount on ${state.coupons[0].title}"
: "No coupons available",
color: const Color(0xFF262626),
size: 14.sp,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 7.h),
GestureDetector(
onTap: () {
// ✅ Updated: Pass callback to bottomsheet
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
builder: (_) => AllCouponsBottomsheet(
onCouponSelected: (selectedCoupon) {
final coupon = selectedCoupon as AllCouponsModel;
// Apply the selected coupon
context.read<CheckoutBloc>().add(
ApplyCouponEvent(
coupon: selectedCoupon),
);
context.read<CheckoutBloc>().add(
ApplyCouponToBackendEvent(
bookingId: widget.bookingId,
couponCode: coupon.couponCode,
),
);
},
),
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CustomText(
text: "View all coupons",
color: const Color(0xFFF95F62),
size: 12.sp,
),
SizedBox(width: 3.w),
const Icon(
Icons.arrow_right,
size: 18,
color: Color(0xFFF95F62),
),
],
),
),
],
),
),
SizedBox(width: 12.w),
/// APPLY / REMOVE BUTTON
GestureDetector(
onTap: () {
if (appliedCoupon != null) {
context.read<CheckoutBloc>().add(
RemoveCouponEvent(bookingId: widget.bookingId),
);
} else if (state.coupons.isNotEmpty) {
// Apply coupon via backend API
context.read<CheckoutBloc>().add(
ApplyCouponToBackendEvent(
bookingId: widget.bookingId,
couponCode: state.coupons[0].couponCode,
),
);
}
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 18.w,
vertical: 10.h,
),
decoration: BoxDecoration(
border: Border.all(
color: const Color(0xFFF95F62),
),
borderRadius: BorderRadius.circular(8.r),
),
child: CustomText(
text: state.isApplyingCoupon
? "Applying..."
: (appliedCoupon != null ? "Remove" : "Apply"),
color: const Color(0xFFF95F62),
size: 14.sp,
),
),
),
],
)
: const SizedBox.shrink(),
),
SizedBox(height: 15.h),
// ✅ PRICING BREAKDOWN
DashedDivider(
color: const Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
// Subtotal
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),
// Discount
if (discountPercentage > 0) ...[
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
CustomText(text: "Discount", size: 14.sp),
CustomText(
text:
"-\$${discountAmount.toStringAsFixed(2)} (${discountPercentage.toStringAsFixed(0)}%)",
size: 14.sp,
weight: FontWeight.w500,
color: Colors.green,
),
],
),
SizedBox(height: 14.h),
],
DashedDivider(
color: const Color(0xFFACACAC),
thickness: 1.h,
dashLength: 4,
dashSpace: 4,
),
SizedBox(height: 10.h),
// Total
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 \$${taxAmount.toStringAsFixed(2)} in taxes",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
],
),
),
CustomText(
text: "\$${finalTotal.toStringAsFixed(2)}",
size: 24.sp,
weight: FontWeight.w500,
),
],
),
const Spacer(),
// ✅ CHECKOUT BUTTON
FutureBuilder<bool>(
future: LocalPreference.getLogin(),
builder: (context, snapshot) {
final isLoggedIn = snapshot.data ?? false;
final isDisabled = isInitiatingPayment || isConfirmingPayment;
return CustomFilledButton(
onTap: isDisabled
? () {} // Empty callback when disabled
: () async {
if (isLoggedIn) {
if (widget.isPurchaseDetailsConfirmed) {
// 🆕 Initiate payment flow
context.read<CheckoutBloc>().add(
InitiatePaymentEvent(
bookingId: widget.bookingId),
);
} else {
// Show purchase details bottom sheet
final result = await PassPurchaseBottomSheet.show(
context, bookingId: widget.bookingId);
// ✅ Handle 'Buy for Myself' - user submitted details
if (result == 'success') {
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: widget.bookingId),
),
);
// If gift details were successfully submitted, mark as confirmed
if (giftResult == 'success') {
widget.onPurchaseDetailsChanged(true);
}
}
}
} else {
Navigator.pop(context);
// Show login bottom sheet if not logged in
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
? (widget.isPurchaseDetailsConfirmed
? (isInitiatingPayment || isConfirmingPayment
? "Processing..."
: "Pay \$${finalTotal.toStringAsFixed(2)}")
: "Checkout")
: "Login to Checkout",
);
},
),
SizedBox(height: 25.h),
],
),
),
),
);
},
);
}
// ✅ Fallback image widget
Widget _fallbackImage() {
return Container(
width: 105.w,
height: 140.h,
color: Colors.grey[200],
child: Icon(
Icons.card_travel,
size: 40.sp,
color: Colors.grey[400],
),
);
}
}