diff --git a/assets/icons/calendar.png b/assets/icons/calendar.png new file mode 100644 index 0000000..c25aff6 Binary files /dev/null and b/assets/icons/calendar.png differ diff --git a/assets/icons/person.png b/assets/icons/person.png new file mode 100644 index 0000000..81d29c0 Binary files /dev/null and b/assets/icons/person.png differ diff --git a/assets/icons/time.png b/assets/icons/time.png new file mode 100644 index 0000000..8042849 Binary files /dev/null and b/assets/icons/time.png differ diff --git a/assets/images/no_itinerary.png b/assets/images/no_itinerary.png new file mode 100644 index 0000000..031c98f Binary files /dev/null and b/assets/images/no_itinerary.png differ diff --git a/assets/images/post_card_intro.png b/assets/images/post_card_intro.png index c1f194c..40ebc91 100644 Binary files a/assets/images/post_card_intro.png and b/assets/images/post_card_intro.png differ diff --git a/assets/images/unlimited_card_details.png b/assets/images/unlimited_card_details.png new file mode 100644 index 0000000..1345db4 Binary files /dev/null and b/assets/images/unlimited_card_details.png differ diff --git a/lib/StripePayment/bloc/stripe_payment_bloc.dart b/lib/StripePayment/bloc/stripe_payment_bloc.dart index 4b63321..79b166f 100644 --- a/lib/StripePayment/bloc/stripe_payment_bloc.dart +++ b/lib/StripePayment/bloc/stripe_payment_bloc.dart @@ -9,21 +9,34 @@ import 'stripe_payment_state.dart'; class StripePaymentBloc extends Bloc { final StripeService _stripeService; + // 🔒 Flag to prevent re-initialization after success + bool _paymentCompleted = false; + StripePaymentBloc({ StripeService? stripeService, }) : _stripeService = stripeService ?? StripeService(), super(const StripePaymentInitial()) { on(_onInitiatePayment); on(_onInitiatePaymentWithClientSecret); + on(_onCancelPayment); on(_onResetPaymentState); + on(_onRetryPayment); } Future _onInitiatePayment( InitiatePayment event, Emitter emit, ) async { + // 🛑 Prevent re-initialization if payment already completed + if (_paymentCompleted) { + debugPrint('âš ī¸ Payment already completed. Ignoring re-initialization.'); + return; + } + try { - emit(const StripePaymentLoading()); + emit(const StripePaymentLoading( + message: 'Creating payment intent...', + )); /// Stripe expects smallest currency unit /// USD → cents, INR → paise @@ -35,6 +48,10 @@ class StripePaymentBloc extends Bloc { currency: event.currency, ); + emit(const StripePaymentLoading( + message: 'Initializing payment sheet...', + )); + // 2ī¸âƒŖ Init Payment Sheet await Stripe.instance.initPaymentSheet( paymentSheetParameters: SetupPaymentSheetParameters( @@ -44,36 +61,43 @@ class StripePaymentBloc extends Bloc { ), ); + emit(const StripePaymentSheetReady()); + + emit(const StripePaymentLoading( + message: 'Processing payment...', + )); + // 3ī¸âƒŖ Show Payment Sheet await Stripe.instance.presentPaymentSheet(); - // ✅ SUCCESS + // ✅ SUCCESS - Mark as completed + _paymentCompleted = true; emit(const StripePaymentSuccess()); } on StripeException catch (e) { - // Handle Stripe-specific errors - if (e.error.code == FailureCode.Canceled) { - emit(StripePaymentCancelled( - message: e.error.localizedMessage ?? 'Payment Cancelled', - )); - } else { - emit(StripePaymentFailure( - error: e.error.localizedMessage ?? 'Payment failed', - )); - } + _handleStripeException(e, emit); } catch (e) { emit(StripePaymentFailure( - error: e.toString(), + error: 'An unexpected error occurred: ${e.toString()}', + isRetryable: true, )); } } - /// 🆕 NEW: Handle payment with clientSecret directly from backend + /// Handle payment with clientSecret directly from backend Future _onInitiatePaymentWithClientSecret( InitiatePaymentWithClientSecret event, Emitter emit, ) async { + // 🛑 Prevent re-initialization if payment already completed + if (_paymentCompleted) { + debugPrint('âš ī¸ Payment already completed. Ignoring re-initialization.'); + return; + } + try { - emit(const StripePaymentLoading()); + emit(const StripePaymentLoading( + message: 'Initializing payment...', + )); // 1ī¸âƒŖ Init Payment Sheet with clientSecret from backend await Stripe.instance.initPaymentSheet( @@ -84,33 +108,127 @@ class StripePaymentBloc extends Bloc { ), ); + emit(const StripePaymentSheetReady()); + + emit(const StripePaymentLoading( + message: 'Processing payment...', + )); + // 2ī¸âƒŖ Show Payment Sheet await Stripe.instance.presentPaymentSheet(); - // ✅ SUCCESS + // ✅ SUCCESS - Mark as completed + _paymentCompleted = true; emit(const StripePaymentSuccess()); } on StripeException catch (e) { - // Handle Stripe-specific errors - if (e.error.code == FailureCode.Canceled) { - emit(StripePaymentCancelled( - message: e.error.localizedMessage ?? 'Payment Cancelled', - )); - } else { - emit(StripePaymentFailure( - error: e.error.localizedMessage ?? 'Payment failed', - )); - } + _handleStripeException(e, emit); } catch (e) { emit(StripePaymentFailure( - error: e.toString(), + error: 'An unexpected error occurred: ${e.toString()}', + isRetryable: true, )); } } + /// Handle payment cancellation + void _onCancelPayment( + CancelPaymentEvent event, + Emitter emit, + ) { + // Only emit cancelled if not already completed + if (!_paymentCompleted) { + emit(const StripePaymentCancelled( + message: 'Payment cancelled by user', + )); + } + } + + /// Handle payment retry + Future _onRetryPayment( + RetryPaymentEvent event, + Emitter emit, + ) async { + // 🔄 Reset completion flag for retry + _paymentCompleted = false; + + // Reset state first + emit(const StripePaymentInitial()); + + // Then initiate payment again + add(InitiatePaymentWithClientSecret( + clientSecret: event.clientSecret, + )); + } + + /// Reset payment state back to initial void _onResetPaymentState( ResetPaymentState event, Emitter emit, ) { + // 🔄 Reset completion flag + _paymentCompleted = false; emit(const StripePaymentInitial()); } + + /// Centralized Stripe exception handling + void _handleStripeException( + StripeException e, + Emitter emit, + ) { + final errorCode = e.error.code; + final errorMessage = e.error.localizedMessage ?? 'Payment failed'; + + // Handle cancellation separately + if (errorCode == FailureCode.Canceled) { + emit(StripePaymentCancelled( + message: errorMessage, + )); + return; + } + + // Handle different error types + switch (errorCode) { + case FailureCode.Failed: + emit(StripePaymentFailure( + error: errorMessage, + errorCode: errorCode.toString(), + isRetryable: true, + )); + break; + + case FailureCode.Timeout: + emit(const StripePaymentFailure( + error: 'Payment timed out. Please try again.', + errorCode: 'timeout', + isRetryable: true, + )); + break; + + default: + emit(StripePaymentFailure( + error: errorMessage, + errorCode: errorCode?.toString(), + isRetryable: _isRetryableError(errorCode), + )); + } + } + + /// Determine if an error is retryable + bool _isRetryableError(FailureCode? errorCode) { + if (errorCode == null) return true; + + // Non-retryable errors + const nonRetryableErrors = [ + // Add specific non-retryable error codes here if needed + ]; + + return !nonRetryableErrors.contains(errorCode); + } + + @override + Future close() { + // Reset flag on bloc disposal + _paymentCompleted = false; + return super.close(); + } } \ No newline at end of file diff --git a/lib/StripePayment/bloc/stripe_payment_event.dart b/lib/StripePayment/bloc/stripe_payment_event.dart index 470e359..ecd1f8d 100644 --- a/lib/StripePayment/bloc/stripe_payment_event.dart +++ b/lib/StripePayment/bloc/stripe_payment_event.dart @@ -20,7 +20,7 @@ class InitiatePayment extends StripePaymentEvent { List get props => [amount, currency]; } -/// 🆕 NEW: Event to initiate payment with clientSecret from backend +/// Event to initiate payment with clientSecret from backend class InitiatePaymentWithClientSecret extends StripePaymentEvent { final String clientSecret; @@ -32,6 +32,24 @@ class InitiatePaymentWithClientSecret extends StripePaymentEvent { List get props => [clientSecret]; } +/// Event to cancel ongoing payment +class CancelPaymentEvent extends StripePaymentEvent { + const CancelPaymentEvent(); +} + +/// Event to reset payment state back to initial class ResetPaymentState extends StripePaymentEvent { const ResetPaymentState(); +} + +/// Event to retry failed payment +class RetryPaymentEvent extends StripePaymentEvent { + final String clientSecret; + + const RetryPaymentEvent({ + required this.clientSecret, + }); + + @override + List get props => [clientSecret]; } \ No newline at end of file diff --git a/lib/StripePayment/bloc/stripe_payment_state.dart b/lib/StripePayment/bloc/stripe_payment_state.dart index 1d6383c..98d4ba5 100644 --- a/lib/StripePayment/bloc/stripe_payment_state.dart +++ b/lib/StripePayment/bloc/stripe_payment_state.dart @@ -7,36 +7,59 @@ abstract class StripePaymentState extends Equatable { List get props => []; } +/// Initial state before any payment action class StripePaymentInitial extends StripePaymentState { const StripePaymentInitial(); } +/// Payment is being processed class StripePaymentLoading extends StripePaymentState { - const StripePaymentLoading(); -} + final String? message; -class StripePaymentSuccess extends StripePaymentState { - final String message; - - const StripePaymentSuccess({ - this.message = 'Payment Successful', + const StripePaymentLoading({ + this.message, }); @override List get props => [message]; } -class StripePaymentFailure extends StripePaymentState { - final String error; +/// Payment sheet is initialized and ready to be presented +class StripePaymentSheetReady extends StripePaymentState { + const StripePaymentSheetReady(); +} - const StripePaymentFailure({ - required this.error, +/// Payment was successful +class StripePaymentSuccess extends StripePaymentState { + final String message; + final String? paymentIntentId; + + const StripePaymentSuccess({ + this.message = 'Payment Successful', + this.paymentIntentId, }); @override - List get props => [error]; + List get props => [message, paymentIntentId]; } +/// Payment failed +class StripePaymentFailure extends StripePaymentState { + final String error; + final String? errorCode; + final bool isRetryable; + + const StripePaymentFailure({ + required this.error, + this.errorCode, + this.isRetryable = true, + }); + + @override + List get props => [error, errorCode, isRetryable]; +} + +/// Payment was cancelled by user class StripePaymentCancelled extends StripePaymentState { final String message; @@ -44,6 +67,30 @@ class StripePaymentCancelled extends StripePaymentState { this.message = 'Payment Cancelled', }); + @override + List get props => [message]; +} + +/// Payment requires additional authentication (3D Secure, etc.) +class StripePaymentRequiresAction extends StripePaymentState { + final String message; + + const StripePaymentRequiresAction({ + this.message = 'Additional authentication required', + }); + + @override + List get props => [message]; +} + +/// Payment is processing on the backend +class StripePaymentProcessing extends StripePaymentState { + final String message; + + const StripePaymentProcessing({ + this.message = 'Payment is being processed...', + }); + @override List get props => [message]; } \ No newline at end of file diff --git a/lib/StripePayment/view/stripe_payment.dart b/lib/StripePayment/view/stripe_payment.dart index 8bd1d47..423f664 100644 --- a/lib/StripePayment/view/stripe_payment.dart +++ b/lib/StripePayment/view/stripe_payment.dart @@ -1,230 +1,475 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + import '../bloc/stripe_payment_bloc.dart'; import '../bloc/stripe_payment_event.dart'; import '../bloc/stripe_payment_state.dart'; import '../repository/stripe_service.dart'; -class StripePaymentView extends StatelessWidget { - const StripePaymentView({super.key}); +/// đŸŽ¯ Reusable Stripe Payment Screen +/// +/// This widget handles Stripe payment flow and can be used across different features +/// like postcards, subscriptions, bookings, etc. +class StripePaymentScreen extends StatelessWidget { + /// Client secret from your backend payment intent + final String clientSecret; - @override - Widget build(BuildContext context) { - final args = - ModalRoute.of(context)!.settings.arguments as Map; + /// Amount to display (optional) + final double? amount; - final double amount = args['amount']; - final String currency = args['currency']; + /// Currency symbol (default: \$) + final String currencySymbol; - return BlocProvider( - create: (context) => StripePaymentBloc( - stripeService: StripeService(), - ), - child: StripePaymentViewContent( - amount: amount, - currency: currency, - ), - ); - } -} + /// Custom title for the payment screen + final String? title; -class StripePaymentViewContent extends StatefulWidget { - final double amount; - final String currency; + /// Custom loading message + final String loadingMessage; - const StripePaymentViewContent({ + /// Custom success message + final String successMessage; + + /// Custom failure message prefix + final String failureMessage; + + /// Callback when payment succeeds + final VoidCallback? onPaymentSuccess; + + /// Callback when payment fails + final void Function(String error)? onPaymentFailure; + + /// Callback when payment is cancelled + final VoidCallback? onPaymentCancelled; + + /// Primary color for the UI + final Color primaryColor; + + /// Success icon color + final Color successColor; + + /// Error icon color + final Color errorColor; + + /// Custom height ratio (0.0 to 1.0) + final double heightRatio; + + /// Whether to show close button during loading + final bool showCloseButtonDuringLoading; + + /// Custom widget to show above the status (optional) + final Widget? headerWidget; + + /// Custom widget to show below the status (optional) + final Widget? footerWidget; + + const StripePaymentScreen({ super.key, - required this.amount, - required this.currency, + required this.clientSecret, + this.amount, + this.currencySymbol = '\$', + this.title, + this.loadingMessage = 'Processing payment...', + this.successMessage = 'Payment Successful!', + this.failureMessage = 'Payment Failed', + this.onPaymentSuccess, + this.onPaymentFailure, + this.onPaymentCancelled, + this.primaryColor = const Color(0xFFF95F62), + this.successColor = Colors.green, + this.errorColor = Colors.red, + this.heightRatio = 0.5, + this.showCloseButtonDuringLoading = false, + this.headerWidget, + this.footerWidget, }); - @override - State createState() => - _StripePaymentViewContentState(); -} + /// 🚀 Static method to show as bottom sheet + static Future showAsBottomSheet({ + required BuildContext context, + required String clientSecret, + double? amount, + String currencySymbol = '\$', + String? title, + String loadingMessage = 'Processing payment...', + String successMessage = 'Payment Successful!', + String failureMessage = 'Payment Failed', + VoidCallback? onPaymentSuccess, + void Function(String error)? onPaymentFailure, + VoidCallback? onPaymentCancelled, + Color primaryColor = const Color(0xFFF95F62), + Color successColor = Colors.green, + Color errorColor = Colors.red, + double heightRatio = 0.5, + bool isDismissible = false, + bool enableDrag = false, + bool showCloseButtonDuringLoading = false, + Widget? headerWidget, + Widget? footerWidget, + }) async { + return await showModalBottomSheet( + context: context, + isDismissible: isDismissible, + enableDrag: enableDrag, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (bottomSheetContext) { + return BlocProvider( + create: (_) => StripePaymentBloc(stripeService: StripeService()) + ..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)), + child: StripePaymentScreen( + clientSecret: clientSecret, + amount: amount, + currencySymbol: currencySymbol, + title: title, + loadingMessage: loadingMessage, + successMessage: successMessage, + failureMessage: failureMessage, + onPaymentSuccess: onPaymentSuccess, + onPaymentFailure: onPaymentFailure, + onPaymentCancelled: onPaymentCancelled, + primaryColor: primaryColor, + successColor: successColor, + errorColor: errorColor, + heightRatio: heightRatio, + showCloseButtonDuringLoading: showCloseButtonDuringLoading, + headerWidget: headerWidget, + footerWidget: footerWidget, + ), + ); + }, + ); + } -class _StripePaymentViewContentState extends State { - @override - void initState() { - super.initState(); - // Automatically initiate payment when screen loads - WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().add( - InitiatePayment( - amount: widget.amount, - currency: widget.currency, - ), - ); - }); + /// 🚀 Static method to show as full screen dialog + static Future showAsDialog({ + required BuildContext context, + required String clientSecret, + double? amount, + String currencySymbol = '\$', + String? title, + String loadingMessage = 'Processing payment...', + String successMessage = 'Payment Successful!', + String failureMessage = 'Payment Failed', + VoidCallback? onPaymentSuccess, + void Function(String error)? onPaymentFailure, + VoidCallback? onPaymentCancelled, + Color primaryColor = const Color(0xFFF95F62), + Color successColor = Colors.green, + Color errorColor = Colors.red, + bool barrierDismissible = false, + bool showCloseButtonDuringLoading = false, + Widget? headerWidget, + Widget? footerWidget, + }) async { + return await showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (dialogContext) { + return BlocProvider( + create: (_) => StripePaymentBloc(stripeService: StripeService()) + ..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)), + child: Dialog( + backgroundColor: Colors.transparent, + child: StripePaymentScreen( + clientSecret: clientSecret, + amount: amount, + currencySymbol: currencySymbol, + title: title, + loadingMessage: loadingMessage, + successMessage: successMessage, + failureMessage: failureMessage, + onPaymentSuccess: onPaymentSuccess, + onPaymentFailure: onPaymentFailure, + onPaymentCancelled: onPaymentCancelled, + primaryColor: primaryColor, + successColor: successColor, + errorColor: errorColor, + heightRatio: 1.0, + showCloseButtonDuringLoading: showCloseButtonDuringLoading, + headerWidget: headerWidget, + footerWidget: footerWidget, + ), + ), + ); + }, + ); } @override Widget build(BuildContext context) { - return BlocListener( + return BlocConsumer( + // 🔒 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) { - // Show success message - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), - ), - ); - // Return success to previous screen - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - Navigator.pop(context, true); + debugPrint('✅ Payment Success - Calling callback'); + // ✅ Call the callback first + onPaymentSuccess?.call(); + // ✅ Then auto-close and return true after 1.5 seconds + Future.delayed(const Duration(milliseconds: 1500), () { + if (context.mounted) { + Navigator.of(context).pop(true); } }); } else if (state is StripePaymentFailure) { - // Show error message - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.error), - backgroundColor: Colors.red, - duration: const Duration(seconds: 3), - ), - ); - // Go back to checkout on error - Future.delayed(const Duration(seconds: 1), () { - if (mounted) { - Navigator.pop(context, false); + debugPrint('❌ Payment Failure - ${state.error}'); + onPaymentFailure?.call(state.error); + // Auto-close after 2 seconds on failure + Future.delayed(const Duration(seconds: 2), () { + if (context.mounted) { + Navigator.of(context).pop(false); } }); } else if (state is StripePaymentCancelled) { - // Show cancellation message - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.orange, - duration: const Duration(seconds: 2), - ), - ); - // Go back to checkout on cancellation - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - Navigator.pop(context, false); - } - }); + debugPrint('đŸšĢ Payment Cancelled'); + onPaymentCancelled?.call(); + Navigator.of(context).pop(false); } }, - child: Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - title: const Text("Processing Payment"), - backgroundColor: Colors.white, - elevation: 0, - automaticallyImplyLeading: false, // Remove back button during processing - centerTitle: true, - ), - body: BlocBuilder( - builder: (context, state) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Loading Indicator - if (state is StripePaymentLoading) ...[ - const CircularProgressIndicator( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation( - Color(0xFFF95F62), - ), - ), - const SizedBox(height: 24), - const Text( - "Preparing secure payment...", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF333333), - ), - ), - const SizedBox(height: 12), - Text( - "Please wait", - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), - ), - ], - - // Amount Display - const SizedBox(height: 32), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFFE0E0E0), - ), - ), - child: Column( - children: [ - Text( - "Payment Amount", - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), - ), - const SizedBox(height: 8), - Text( - "\$${widget.amount.toStringAsFixed(2)}", - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: Color(0xFF333333), - ), - ), - const SizedBox(height: 4), - Text( - widget.currency.toUpperCase(), - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), - ), - ], - ), - ), - - const SizedBox(height: 32), - - // Security Badge - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.lock_outline, - size: 16, - color: Colors.grey[600], - ), - const SizedBox(width: 6), - Text( - "Secured by Stripe", - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), - ), + 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 + ? MediaQuery.of(context).size.height + : MediaQuery.of(context).size.height * heightRatio, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: heightRatio == 1.0 + ? null + : const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Stack( + children: [ + // Main content + Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Custom header widget + if (headerWidget != null) ...[ + headerWidget!, + const SizedBox(height: 16), ], - ), - ], + + // Title + if (title != null) ...[ + Text( + title!, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF333333), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + ], + + // Amount display + if (amount != null) ...[ + Text( + '$currencySymbol${amount!.toStringAsFixed(2)}', + style: TextStyle( + fontSize: 32.sp, + fontWeight: FontWeight.w700, + color: primaryColor, + ), + ), + const SizedBox(height: 24), + ], + + // Payment status + _buildPaymentStatus(context, state), + + // Custom footer widget + if (footerWidget != null) ...[ + const SizedBox(height: 16), + footerWidget!, + ], + ], + ), ), ), - ); - }, - ), - ), + + // Close button (only show when allowed) + if (_shouldShowCloseButton(state)) + Positioned( + top: 16, + right: 16, + child: IconButton( + onPressed: () { + if (state is StripePaymentLoading) { + // Cancel payment if loading + context + .read() + .add(CancelPaymentEvent()); + } else { + Navigator.of(context).pop(false); + } + }, + icon: Icon( + Icons.close, + color: Colors.grey[600], + size: 24, + ), + ), + ), + ], + ), + ); + }, ); } + + /// Build payment status widget based on state + Widget _buildPaymentStatus(BuildContext context, StripePaymentState state) { + if (state is StripePaymentLoading) { + return Column( + children: [ + CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(primaryColor), + ), + const SizedBox(height: 24), + Text( + loadingMessage, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF333333), + ), + textAlign: TextAlign.center, + ), + ], + ); + } else if (state is StripePaymentSuccess) { + return Column( + children: [ + Icon( + Icons.check_circle, + color: successColor, + size: 64, + ), + const SizedBox(height: 16), + Text( + successMessage, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF333333), + ), + textAlign: TextAlign.center, + ), + ], + ); + } else if (state is StripePaymentFailure) { + return Column( + children: [ + Icon( + Icons.error, + color: errorColor, + size: 64, + ), + const SizedBox(height: 16), + Text( + failureMessage, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF333333), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + state.error, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // Retry payment + context.read().add( + RetryPaymentEvent( + clientSecret: clientSecret, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Retry Payment', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } else if (state is StripePaymentCancelled) { + return Column( + children: [ + Icon( + Icons.cancel, + color: Colors.orange, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Payment Cancelled', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF333333), + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + return const SizedBox.shrink(); + } + + /// Determine if close button should be shown + bool _shouldShowCloseButton(StripePaymentState state) { + if (state is StripePaymentLoading) { + return showCloseButtonDuringLoading; + } + // Show for failure and cancelled states + return state is StripePaymentFailure || state is StripePaymentCancelled; + } } \ No newline at end of file diff --git a/lib/add_details/add_details_view.dart b/lib/add_details/add_details_view.dart index 0b79bf2..22c7626 100644 --- a/lib/add_details/add_details_view.dart +++ b/lib/add_details/add_details_view.dart @@ -81,12 +81,12 @@ class _AddDetailsViewState extends State { // 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 { selectedCountry = value; }); }, - items: ["India", "USA", "UK", "Canada"] + items: ["Australia"] .map((value) { return DropdownMenuItem( value: value, diff --git a/lib/attraction_details/widgets/share_bottomsheet.dart b/lib/attraction_details/widgets/share_bottomsheet.dart index 8358ca0..98d0fc8 100644 --- a/lib/attraction_details/widgets/share_bottomsheet.dart +++ b/lib/attraction_details/widgets/share_bottomsheet.dart @@ -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,26 +93,32 @@ class ShareBottomSheet extends StatelessWidget { ); }, ), + const SizedBox(height: 20), + + // page indicator Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate( 4, - (index) => Container( + (index) => Container( margin: const EdgeInsets.symmetric(horizontal: 3), 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), ], ), ); } -} +} \ No newline at end of file diff --git a/lib/attractions/models/attraction_model.dart b/lib/attractions/models/attraction_model.dart index be46ea6..d2326c8 100644 --- a/lib/attractions/models/attraction_model.dart +++ b/lib/attractions/models/attraction_model.dart @@ -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 { @@ -300,5 +296,4 @@ class Category { 'categoryName': categoryName, }; } -} - +} \ No newline at end of file diff --git a/lib/attractions/widget/attraction_card.dart b/lib/attractions/widget/attraction_card.dart index eb8abf8..fc0ec13 100644 --- a/lib/attractions/widget/attraction_card.dart +++ b/lib/attractions/widget/attraction_card.dart @@ -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, - ), - ), - ), ], ), ), diff --git a/lib/buy_a_pass/models/buy_pass_model.dart b/lib/buy_a_pass/models/buy_pass_model.dart index 73f5908..fb8858d 100644 --- a/lib/buy_a_pass/models/buy_pass_model.dart +++ b/lib/buy_a_pass/models/buy_pass_model.dart @@ -8,10 +8,10 @@ String buyPassModelToJson(BuyPassModel data) => json.encode(data.toJson()); class BuyPassModel { - final City city; - final List offers; - final List cards; - final List attractions; + City city; + List offers; + List cards; + List attractions; BuyPassModel({ required this.city, @@ -20,41 +20,49 @@ class BuyPassModel { required this.attractions, }); - factory BuyPassModel.fromJson(Map json) { + factory BuyPassModel.fromJson(Map? json) { + json ??= {}; + return BuyPassModel( city: City.fromJson(json['city']), - offers: List.from( - json['offers'].map((x) => Offer.fromJson(x)), - ), - cards: List.from( - json['cards'].map((x) => CardPass.fromJson(x)), - ), - attractions: List.from( - json['attractions'].map((x) => Attraction.fromJson(x)), - ), + offers: json['offers'] == null + ? [] + : List>.from(json['offers']) + .map((e) => Offer.fromJson(e)) + .toList(), + cards: json['cards'] == null + ? [] + : List>.from(json['cards']) + .map((e) => CardPass.fromJson(e)) + .toList(), + attractions: json['attractions'] == null + ? [] + : List>.from(json['attractions']) + .map((e) => Attraction.fromJson(e)) + .toList(), ); } Map toJson() => { "city": city.toJson(), - "offers": offers.map((x) => x.toJson()).toList(), - "cards": cards.map((x) => x.toJson()).toList(), - "attractions": attractions.map((x) => x.toJson()).toList(), + "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 json) { + factory City.fromJson(Map? 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 json) { + factory HeroBanner.fromJson(Map? 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 json) { + factory Offer.fromJson(Map? 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 offers; + int id; + String title; + String description; + int validityDuration; + num adultPrice; + num childPrice; + int minNumber; + int maxNumber; + CardType cardType; + List offers; CardPass({ required this.id, @@ -210,20 +226,24 @@ class CardPass { required this.offers, }); - factory CardPass.fromJson(Map json) { + factory CardPass.fromJson(Map? 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.from( - json['offers'].map((x) => Offer.fromJson(x)), - ), + offers: json['offers'] == null + ? [] + : List>.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 json) { + factory CardType.fromJson(Map? 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 json) { + factory Attraction.fromJson(Map? 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, ); } @@ -301,4 +325,4 @@ class Attraction { "thumbnail": thumbnail, "startingFrom": startingFrom, }; -} \ No newline at end of file +} diff --git a/lib/buy_a_pass/repository/buy_pass_repository.dart b/lib/buy_a_pass/repository/buy_pass_repository.dart index 643804b..1144608 100644 --- a/lib/buy_a_pass/repository/buy_pass_repository.dart +++ b/lib/buy_a_pass/repository/buy_pass_repository.dart @@ -27,10 +27,11 @@ class BuyPassRepository { required int totalChild, required int noOfAttractions, required int noOfDays, + required double baseAmount, }) async { try { final response = await _apiService.postApi( - url: ApiUrls.addToCartPasses, // add this key in ApiUrls + url: ApiUrls.addToCartPasses, data: { "cityXid": cityXid, "cardTypeXid": cardTypeXid, @@ -38,6 +39,8 @@ class BuyPassRepository { "cardMode": cardMode, "totalAdult": totalAdult, "totalChild": totalChild, + "baseAmount": baseAmount, + "taxAmount": 2, // Fixed tax amount "noOfAttractions": noOfAttractions, "noOfDays": noOfDays, }, @@ -48,4 +51,4 @@ class BuyPassRepository { throw Exception('Failed to add passes to cart: $e'); } } -} +} \ No newline at end of file diff --git a/lib/buy_a_pass/view/buy_pass_view.dart b/lib/buy_a_pass/view/buy_pass_view.dart index 32905e5..d0169fc 100644 --- a/lib/buy_a_pass/view/buy_pass_view.dart +++ b/lib/buy_a_pass/view/buy_pass_view.dart @@ -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), diff --git a/lib/buy_a_pass/widget/payment_card_view.dart b/lib/buy_a_pass/widget/payment_card_view.dart index e2e2036..c8db07b 100644 --- a/lib/buy_a_pass/widget/payment_card_view.dart +++ b/lib/buy_a_pass/widget/payment_card_view.dart @@ -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, @@ -181,11 +181,12 @@ class PaymentCard extends StatelessWidget { cityXid: cityXid, cardTypeXid: cardTypeXid, cardXid: cardXid, - cardMode: isSelectivePass ? 'flexi' : 'fixed', + cardMode: isSelectivePass ? 'flexi' : 'unlimited', totalAdult: adults, totalChild: children, noOfAttractions: isSelectivePass ? selectedValue : 0, noOfDays: isUnlimitedCard ? selectedValue : 0, + baseAmount: totalPrice, ); // ✅ Extract bookingId from response diff --git a/lib/cart/blocs/myPassCart/my_pass_cart_bloc.dart b/lib/cart/blocs/myPassCart/my_pass_cart_bloc.dart index 67607d4..81fbe81 100644 --- a/lib/cart/blocs/myPassCart/my_pass_cart_bloc.dart +++ b/lib/cart/blocs/myPassCart/my_pass_cart_bloc.dart @@ -8,18 +8,122 @@ class MyPassCartBloc extends Bloc { final MyPassCartRepository repository; MyPassCartBloc({required this.repository}) : super(const MyPassCartInitial()) { + on(_onCheckLoginAndFetch); on(_onFetchPassCart); on(_onClearPassCart); } - /// Handle fetching pass cart data + /// Handle checking login status and fetching cart data accordingly + Future _onCheckLoginAndFetch( + CheckLoginAndFetchEvent event, + Emitter 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 _onFetchPassCart( FetchPassCartEvent event, Emitter 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 { ) async { try { if (kDebugMode) { - print('🔄 [BLOC] Clearing pass cart...'); + print('📄 [BLOC] Clearing pass cart...'); } // You can add clearPassCart method to repository if needed diff --git a/lib/cart/blocs/myPassCart/my_pass_cart_event.dart b/lib/cart/blocs/myPassCart/my_pass_cart_event.dart index 5bd32ad..da61222 100644 --- a/lib/cart/blocs/myPassCart/my_pass_cart_event.dart +++ b/lib/cart/blocs/myPassCart/my_pass_cart_event.dart @@ -7,6 +7,14 @@ abstract class MyPassCartEvent extends Equatable { List 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(); diff --git a/lib/cart/blocs/myPassCart/my_pass_cart_state.dart b/lib/cart/blocs/myPassCart/my_pass_cart_state.dart index 3d6ea24..4dadab0 100644 --- a/lib/cart/blocs/myPassCart/my_pass_cart_state.dart +++ b/lib/cart/blocs/myPassCart/my_pass_cart_state.dart @@ -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 cartData; @@ -27,6 +29,16 @@ class MyPassCartLoaded extends MyPassCartState { List get props => [cartData]; } +/// Loaded state with cart data from API +class MyPassCartApiLoaded extends MyPassCartState { + final MyPassesCartModel apiCartData; + + const MyPassCartApiLoaded({required this.apiCartData}); + + @override + List get props => [apiCartData]; +} + /// Empty state when no cart data exists class MyPassCartEmpty extends MyPassCartState { const MyPassCartEmpty(); diff --git a/lib/cart/blocs/pass_bloc.dart b/lib/cart/blocs/pass_bloc.dart index 03b3d02..6356c86 100644 --- a/lib/cart/blocs/pass_bloc.dart +++ b/lib/cart/blocs/pass_bloc.dart @@ -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 passes; - final double subtotal; - final double discountPercent; - final double total; - - PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total); -} - -class PassBloc extends Bloc { - PassBloc() : super(PassLoading()) { - on((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 passes; +// final double subtotal; +// final double discountPercent; +// final double total; +// +// PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total); +// } +// +// class PassBloc extends Bloc { +// PassBloc() : super(PassLoading()) { +// on((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)); +// }); +// } +// } diff --git a/lib/cart/model/my_passes_cart_mode.dart b/lib/cart/model/my_passes_cart_mode.dart new file mode 100644 index 0000000..ff8eb55 --- /dev/null +++ b/lib/cart/model/my_passes_cart_mode.dart @@ -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 cartItems; + + MyPassesCartModel({ + required this.city, + required this.cartItems, + }); + + factory MyPassesCartModel.fromJson(Map? json) { + json ??= {}; + + return MyPassesCartModel( + city: CartCity.fromJson(json['city']), + cartItems: json['cartItems'] == null + ? [] + : List>.from(json['cartItems']) + .map((e) => CartItem.fromJson(e)) + .toList(), + ); + } + + Map 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? json) { + json ??= {}; + + return CartCity( + id: (json['id'] as num?)?.toInt() ?? 0, + name: json['name']?.toString() ?? "", + ); + } + + Map 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? 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 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? json) { + json ??= {}; + + return ItemCity( + id: (json['id'] as num?)?.toInt() ?? 0, + cityName: json['cityName']?.toString() ?? "", + ); + } + + Map toJson() => { + "id": id, + "cityName": cityName, + }; +} diff --git a/lib/cart/repository/my_pass_cart_repository.dart b/lib/cart/repository/my_pass_cart_repository.dart index c4b9be3..cab9cbf 100644 --- a/lib/cart/repository/my_pass_cart_repository.dart +++ b/lib/cart/repository/my_pass_cart_repository.dart @@ -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 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?> 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 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; + } + } + } \ No newline at end of file diff --git a/lib/cart/views/my_pass_cart_page_view.dart b/lib/cart/views/my_pass_cart_page_view.dart index 6f8d349..1830026 100644 --- a/lib/cart/views/my_pass_cart_page_view.dart +++ b/lib/cart/views/my_pass_cart_page_view.dart @@ -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 { // 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().add(const FetchPassCartEvent()); + context.read().add(const CheckLoginAndFetchEvent()); } @override @@ -38,36 +41,42 @@ class _MyPassesPageState extends State { 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 { 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 { 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( + 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( + 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 { fontSize: 16.sp, ), ), - // TextSpan( - // text: "Card", - // style: TextStyle( - // color: Colors.white, - // fontSize: 12.sp, - // ), - // ), ], ), ), @@ -402,42 +840,10 @@ class _MyPassesPageState extends State { ], ), SizedBox(height: 150.h), - - // FutureBuilder for login check - FutureBuilder( - 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: [ diff --git a/lib/checkout/bloc/checkOut/checkout_bloc.dart b/lib/checkout/bloc/checkOut/checkout_bloc.dart index 15abcd6..f5b6b4e 100644 --- a/lib/checkout/bloc/checkOut/checkout_bloc.dart +++ b/lib/checkout/bloc/checkOut/checkout_bloc.dart @@ -15,6 +15,7 @@ class CheckoutBloc extends Bloc { on(_onFetchCheckoutCoupons); on(_onApplyCoupon); on(_onRemoveCoupon); + on(_onApplyCouponToBackend); // 🆕 NEW on(_onInitiatePayment); // 🆕 NEW on(_onConfirmPayment); // 🆕 NEW } @@ -42,13 +43,77 @@ class CheckoutBloc extends Bloc { } } - void _onRemoveCoupon( + Future _onRemoveCoupon( RemoveCouponEvent event, Emitter emit, - ) { + ) async { if (state is CheckoutCouponsLoadedState) { final currentState = state as CheckoutCouponsLoadedState; - emit(currentState.copyWith(clearAppliedCoupon: true)); + + // Show loading + emit(currentState.copyWith(isApplyingCoupon: true, couponError: null)); + + try { + // Call API with empty coupon code + await checkoutRepository.applyCoupon( + bookingId: event.bookingId, + couponCode: '', // Empty string to remove coupon + ); + + // Clear applied coupon from state + emit(currentState.copyWith( + clearAppliedCoupon: true, + isApplyingCoupon: false, + couponError: null, + )); + } catch (e) { + emit(currentState.copyWith( + isApplyingCoupon: false, + couponError: e.toString(), + )); + } + } + } + + /// 🆕 Apply Coupon to Backend + /// Calls the PUT /apply-coupon API + Future _onApplyCouponToBackend( + ApplyCouponToBackendEvent event, + Emitter emit, + ) async { + if (state is CheckoutCouponsLoadedState) { + final currentState = state as CheckoutCouponsLoadedState; + + // Show loading + emit(currentState.copyWith(isApplyingCoupon: true, couponError: null)); + + try { + // Call API + final response = await checkoutRepository.applyCoupon( + bookingId: event.bookingId, + couponCode: event.couponCode, + ); + + // Find the coupon from the list + final appliedCoupon = currentState.coupons.firstWhere( + (c) => c.couponCode == event.couponCode, + orElse: () => currentState.coupons.first, + ); + + // Update state with applied coupon + emit(currentState.copyWith( + appliedCoupon: appliedCoupon, + isApplyingCoupon: false, + couponError: null, + )); + + // Success message will be handled in view + } catch (e) { + emit(currentState.copyWith( + isApplyingCoupon: false, + couponError: e.toString(), + )); + } } } @@ -132,6 +197,15 @@ class CheckoutBloc extends Bloc { ConfirmPaymentEvent event, Emitter 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; @@ -139,6 +213,7 @@ class CheckoutBloc extends Bloc { isConfirmingPayment: true, confirmationError: null, isPaymentConfirmed: false, + hasConfirmationBeenSent: true, // 🔒 Mark as sent )); } else { emit(CheckoutPaymentConfirmingState()); @@ -174,6 +249,7 @@ class CheckoutBloc extends Bloc { isConfirmingPayment: false, isPaymentConfirmed: false, confirmationError: e.toString(), + hasConfirmationBeenSent: false, // 🔓 Reset on error to allow retry )); } else { emit(CheckoutPaymentConfirmationErrorState( diff --git a/lib/checkout/bloc/checkOut/checkout_event.dart b/lib/checkout/bloc/checkOut/checkout_event.dart index 4e0677f..631d8ba 100644 --- a/lib/checkout/bloc/checkOut/checkout_event.dart +++ b/lib/checkout/bloc/checkOut/checkout_event.dart @@ -8,8 +8,22 @@ class ApplyCouponEvent extends CheckoutEvent { final AllCouponsModel coupon; ApplyCouponEvent({required this.coupon}); } +/// 🆕 Apply Coupon to Backend Event +class ApplyCouponToBackendEvent extends CheckoutEvent { + final int bookingId; + final String couponCode; -class RemoveCouponEvent extends CheckoutEvent {} + ApplyCouponToBackendEvent({ + required this.bookingId, + required this.couponCode, + }); +} + +class RemoveCouponEvent extends CheckoutEvent { + final int bookingId; + + RemoveCouponEvent({required this.bookingId}); +} /// 🆕 Initiate Payment Event /// Triggered when user clicks "Pay" button diff --git a/lib/checkout/bloc/checkOut/checkout_state.dart b/lib/checkout/bloc/checkOut/checkout_state.dart index b5e5a9e..fd638a9 100644 --- a/lib/checkout/bloc/checkOut/checkout_state.dart +++ b/lib/checkout/bloc/checkOut/checkout_state.dart @@ -10,6 +10,10 @@ class CheckoutCouponsLoadedState extends CheckoutState { final List coupons; final AllCouponsModel? appliedCoupon; + // 🆕 Coupon application tracking + final bool isApplyingCoupon; + final String? couponError; + // 🆕 Payment-related fields final bool isInitiatingPayment; final String? clientSecret; // Stripe client secret @@ -21,10 +25,13 @@ class CheckoutCouponsLoadedState extends CheckoutState { final bool isPaymentConfirmed; final String? confirmationError; final Map? bookingDetails; // Full booking response after confirmation + final bool hasConfirmationBeenSent; // 🔒 Prevent duplicate confirmation calls CheckoutCouponsLoadedState({ required this.coupons, this.appliedCoupon, + this.isApplyingCoupon = false, + this.couponError, this.isInitiatingPayment = false, this.clientSecret, this.bookingId, @@ -33,12 +40,15 @@ class CheckoutCouponsLoadedState extends CheckoutState { this.isPaymentConfirmed = false, this.confirmationError, this.bookingDetails, + this.hasConfirmationBeenSent = false, }); CheckoutCouponsLoadedState copyWith({ List? coupons, AllCouponsModel? appliedCoupon, bool clearAppliedCoupon = false, + bool? isApplyingCoupon, + String? couponError, bool? isInitiatingPayment, String? clientSecret, int? bookingId, @@ -48,10 +58,13 @@ class CheckoutCouponsLoadedState extends CheckoutState { String? confirmationError, bool clearClientSecret = false, Map? bookingDetails, + bool? hasConfirmationBeenSent, }) { return CheckoutCouponsLoadedState( coupons: coupons ?? this.coupons, appliedCoupon: clearAppliedCoupon ? null : (appliedCoupon ?? this.appliedCoupon), + isApplyingCoupon: isApplyingCoupon ?? this.isApplyingCoupon, + couponError: couponError, isInitiatingPayment: isInitiatingPayment ?? this.isInitiatingPayment, bookingId: bookingId ?? this.bookingId, paymentError: paymentError, @@ -60,6 +73,7 @@ class CheckoutCouponsLoadedState extends CheckoutState { confirmationError: confirmationError, clientSecret: clearClientSecret ? null : (clientSecret ?? this.clientSecret), bookingDetails: bookingDetails ?? this.bookingDetails, + hasConfirmationBeenSent: hasConfirmationBeenSent ?? this.hasConfirmationBeenSent, ); } } diff --git a/lib/checkout/repository/pass_purchase_details_repository.dart b/lib/checkout/repository/pass_purchase_details_repository.dart index 34826e1..7ee0d5c 100644 --- a/lib/checkout/repository/pass_purchase_details_repository.dart +++ b/lib/checkout/repository/pass_purchase_details_repository.dart @@ -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'); diff --git a/lib/checkout/view/checkout_view.dart b/lib/checkout/view/checkout_view.dart index 6cb3e39..18e5f91 100644 --- a/lib/checkout/view/checkout_view.dart +++ b/lib/checkout/view/checkout_view.dart @@ -10,14 +10,13 @@ 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/bloc/stripe_payment_bloc.dart'; -import '../../StripePayment/bloc/stripe_payment_event.dart'; -import '../../StripePayment/bloc/stripe_payment_state.dart'; -import '../../StripePayment/repository/stripe_service.dart'; +import '../../StripePayment/view/stripe_payment.dart'; import '../../add_details/add_details_view.dart'; import '../../buy_a_pass/models/checkout_model.dart'; -import '../../common_bloc/bottom_navigation_bloc.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'; @@ -105,7 +104,7 @@ class _CheckoutViewState extends State { } } -class _CheckoutContent extends StatelessWidget { +class _CheckoutContent extends StatefulWidget { final CheckoutData checkoutData; final int bookingId; final bool isPurchaseDetailsConfirmed; @@ -118,232 +117,73 @@ 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 - Future _handlePaymentFlow(BuildContext context, String clientSecret, int bookingId) async { - // Show payment bottom sheet with BLoC - final paymentResult = await showModalBottomSheet>( + /// 🆕 Handle payment flow with client secret - SIMPLIFIED VERSION + Future _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, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (bottomSheetContext) { - return BlocProvider( - create: (_) => StripePaymentBloc(stripeService: StripeService()) - ..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)), - child: BlocConsumer( - listener: (context, state) { - if (state is StripePaymentSuccess) { - // Return success with stripe status - Navigator.of(bottomSheetContext).pop({ - 'success': true, - 'stripeStatus': 'succeeded', - 'paymentStatus': 'success', - }); - } else if (state is StripePaymentFailure) { - // Return failure with stripe status - Navigator.of(bottomSheetContext).pop({ - 'success': false, - 'stripeStatus': 'requires_payment_method', - 'paymentStatus': 'failed', - 'error': state.error, - }); - } else if (state is StripePaymentCancelled) { - // Return cancelled status - Navigator.of(bottomSheetContext).pop({ - 'success': false, - 'stripeStatus': 'cancelled', - 'paymentStatus': 'cancelled', - }); - } - }, - builder: (context, state) { - return Container( - height: MediaQuery.of(context).size.height * 0.5, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (state is StripePaymentLoading) ...[ - const CircularProgressIndicator( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation( - Color(0xFFF95F62), - ), - ), - const SizedBox(height: 24), - const Text( - "Processing payment...", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF333333), - ), - ), - ] else if (state is StripePaymentSuccess) ...[ - const Icon( - Icons.check_circle, - color: Colors.green, - size: 64, - ), - const SizedBox(height: 16), - const Text( - "Payment Successful!", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF333333), - ), - ), - ] else if (state is StripePaymentFailure) ...[ - const Icon( - Icons.error, - color: Colors.red, - size: 64, - ), - const SizedBox(height: 16), - const Text( - "Payment Failed", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF333333), - ), - ), - const SizedBox(height: 8), - Text( - state.error, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - Navigator.of(bottomSheetContext).pop({ - 'success': false, - 'stripeStatus': 'requires_payment_method', - 'paymentStatus': 'failed', - }); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFF95F62), - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 12, - ), - ), - child: const Text( - "Close", - style: TextStyle(color: Colors.white), - ), - ), - ] else if (state is StripePaymentCancelled) ...[ - const Icon( - Icons.cancel, - color: Colors.orange, - size: 64, - ), - const SizedBox(height: 16), - const Text( - "Payment Cancelled", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF333333), - ), - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - Navigator.of(bottomSheetContext).pop({ - 'success': false, - 'stripeStatus': 'cancelled', - 'paymentStatus': 'cancelled', - }); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFF95F62), - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 12, - ), - ), - child: const Text( - "Close", - style: TextStyle(color: Colors.white), - ), - ), - ], - ], - ), - ), - ), - ); - }, + onPaymentSuccess: () { + context.read().add( + ConfirmPaymentEvent( + bookingId: bookingId, + stripeStatus: 'succeeded', + paymentStatus: 'success', + ), + ); + }, + onPaymentFailure: (error) { + context.read().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, ), ); }, ); - // Handle payment result - if (paymentResult != null) { - final success = paymentResult['success'] as bool? ?? false; - final stripeStatus = paymentResult['stripeStatus'] as String? ?? 'unknown'; - final paymentStatus = paymentResult['paymentStatus'] as String? ?? 'unknown'; + // ✅ USE paymentSuccess HERE + if (paymentSuccess == true && context.mounted) { + // Wait a moment for backend confirmation + await Future.delayed(const Duration(milliseconds: 500)); - if (success) { - // Payment successful - confirm with backend - if (context.mounted) { - // context.read().add( - // ConfirmPaymentEvent( - // bookingId: bookingId, - // stripeStatus: stripeStatus, - // paymentStatus: paymentStatus, - // ), - // ); - ScaffoldMessenger.of(context).showSnackBar( + // Navigate to home after successful payment + + Navigator.of(context).popUntil((route) => route.isFirst); + context.read().add(CheckLoginAndFetchItinerary()); + context.read().add(CheckLoginAndFetchPasses()); + ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Payment confirmed successfully!'), backgroundColor: Colors.green, ), ); - context.read().add(NavigationTabChanged(2)); - } - - } else { - // Payment failed or cancelled - still confirm with backend - if (context.mounted) { - // context.read().add( - // ConfirmPaymentEvent( - // bookingId: bookingId, - // stripeStatus: stripeStatus, - // paymentStatus: paymentStatus, - // ), - // ); - - // Show error message - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - paymentResult['error'] as String? ?? 'Payment failed. Please try again.', - ), - backgroundColor: Colors.red, - ), - ); - } - } } } @@ -353,24 +193,39 @@ 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) { - // Trigger payment flow + // 🔒 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 ?? bookingId); + _handlePaymentFlow( + context, + state.clientSecret!, + state.bookingId ?? widget.bookingId, + finalTotal, // ✅ Pass the calculated finalTotal + ); }); } // 🆕 Listen for payment confirmation success if (state.isPaymentConfirmed) { - // Show success message - // ScaffoldMessenger.of(context).showSnackBar( - // const SnackBar( - // content: Text('Payment confirmed successfully!'), - // backgroundColor: Colors.green, - // ), - // ); - // Navigate to success page or back Future.delayed(const Duration(seconds: 2), () { if (context.mounted) { @@ -426,11 +281,12 @@ 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 taxRate = 0.05; // 5% tax final double totalBeforeTax = subtotal - discountAmount; - final double taxAmount = totalBeforeTax * taxRate; + // final double taxAmount = totalBeforeTax * taxRate; + final double taxAmount = 2; final double finalTotal = totalBeforeTax + taxAmount; return Scaffold( @@ -469,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), ), @@ -484,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, @@ -506,7 +362,7 @@ class _CheckoutContent extends StatelessWidget { height: 24.w, child: CircularProgressIndicator( strokeWidth: 2, - color: checkoutData.themeColor, + color: widget.checkoutData.themeColor, ), ), ), @@ -525,7 +381,7 @@ class _CheckoutContent extends StatelessWidget { children: [ // City Name CustomText( - text: checkoutData.cityName, + text: widget.checkoutData.cityName, weight: FontWeight.w500, size: 16.sp, ), @@ -533,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, ), @@ -547,7 +403,7 @@ class _CheckoutContent extends StatelessWidget { MainAxisAlignment.spaceBetween, children: [ // Adults - if (checkoutData.adultCount > 0) + if (widget.checkoutData.adultCount > 0) Row( children: [ Image.asset( @@ -557,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, ), @@ -570,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, @@ -578,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, ), @@ -591,7 +447,7 @@ class _CheckoutContent extends StatelessWidget { text: "\$${subtotal.toStringAsFixed(2)}", size: 24.sp, weight: FontWeight.w500, - color: checkoutData.themeColor, + color: widget.checkoutData.themeColor, ), ], ), @@ -605,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), @@ -615,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, @@ -704,11 +560,18 @@ class _CheckoutContent extends StatelessWidget { ), builder: (_) => AllCouponsBottomsheet( onCouponSelected: (selectedCoupon) { + final coupon = selectedCoupon as AllCouponsModel; // Apply the selected coupon context.read().add( ApplyCouponEvent( coupon: selectedCoupon), ); + context.read().add( + ApplyCouponToBackendEvent( + bookingId: widget.bookingId, + couponCode: coupon.couponCode, + ), + ); }, ), ); @@ -740,13 +603,16 @@ class _CheckoutContent extends StatelessWidget { GestureDetector( onTap: () { if (appliedCoupon != null) { - context - .read() - .add(RemoveCouponEvent()); - } else if (state.coupons.isNotEmpty) { context.read().add( - ApplyCouponEvent( - coupon: state.coupons[0]), + RemoveCouponEvent(bookingId: widget.bookingId), + ); + } else if (state.coupons.isNotEmpty) { + // Apply coupon via backend API + context.read().add( + ApplyCouponToBackendEvent( + bookingId: widget.bookingId, + couponCode: state.coupons[0].couponCode, + ), ); } }, @@ -762,8 +628,9 @@ class _CheckoutContent extends StatelessWidget { borderRadius: BorderRadius.circular(8.r), ), child: CustomText( - text: - appliedCoupon != null ? "Remove" : "Apply", + text: state.isApplyingCoupon + ? "Applying..." + : (appliedCoupon != null ? "Remove" : "Apply"), color: const Color(0xFFF95F62), size: 14.sp, ), @@ -868,32 +735,32 @@ class _CheckoutContent extends StatelessWidget { ? () {} // Empty callback when disabled : () async { if (isLoggedIn) { - if (isPurchaseDetailsConfirmed) { + if (widget.isPurchaseDetailsConfirmed) { // 🆕 Initiate payment flow context.read().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( 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); } } } @@ -915,7 +782,7 @@ class _CheckoutContent extends StatelessWidget { }, width: double.infinity, label: isLoggedIn - ? (isPurchaseDetailsConfirmed + ? (widget.isPurchaseDetailsConfirmed ? (isInitiatingPayment || isConfirmingPayment ? "Processing..." : "Pay \$${finalTotal.toStringAsFixed(2)}") diff --git a/lib/checkout/widget/pass_purchase_details_bottomsheet.dart b/lib/checkout/widget/pass_purchase_details_bottomsheet.dart index b7c0771..ecee655 100644 --- a/lib/checkout/widget/pass_purchase_details_bottomsheet.dart +++ b/lib/checkout/widget/pass_purchase_details_bottomsheet.dart @@ -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 diff --git a/lib/common_packages/common_app_texts.dart b/lib/common_packages/common_app_texts.dart index 402c069..af9a14c 100644 --- a/lib/common_packages/common_app_texts.dart +++ b/lib/common_packages/common_app_texts.dart @@ -1,3 +1,3 @@ class CommonAppText { - static const String selectiveCard = "Selective"; + static const String selectiveCard = "Flexi"; } \ No newline at end of file diff --git a/lib/common_packages/custom_dash_border_painter.dart b/lib/common_packages/custom_dash_border_painter.dart new file mode 100644 index 0000000..40f488d --- /dev/null +++ b/lib/common_packages/custom_dash_border_painter.dart @@ -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; +} diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 8079eba..7dac309 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -15,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'; @@ -28,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'; @@ -70,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 args = settings.arguments as Map; + 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: (_) { @@ -150,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); }, ); @@ -168,9 +201,7 @@ class AppRouter { final bookingId = settings.arguments as int; // or String return MaterialPageRoute( - builder: (_) => CheckoutView( - bookingId: bookingId, - ), + builder: (_) => CheckoutView(bookingId: bookingId), ); @@ -190,15 +221,23 @@ 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; return MaterialPageRoute( builder: (_) { - return AddDetailsView( - bookingId: bookingId, - ); + return AddDetailsView(bookingId: bookingId); }, ); diff --git a/lib/core/global_keys.dart b/lib/core/global_keys.dart new file mode 100644 index 0000000..8ae3c31 --- /dev/null +++ b/lib/core/global_keys.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +class GlobalKeys { + static final GlobalKey scaffoldMessengerKey = + GlobalKey(); +} \ No newline at end of file diff --git a/lib/core/inside_bottom_navigator.dart b/lib/core/inside_bottom_navigator.dart index 8173f63..cad4181 100644 --- a/lib/core/inside_bottom_navigator.dart +++ b/lib/core/inside_bottom_navigator.dart @@ -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 args = settings.arguments as Map; + 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(context); - return BlocProvider.value( - value: previousBloc, - child: const QrPassView(), + return BlocProvider( + create: (context) => MyPassesDetailsBloc( + repository: MyPassesDetailsRepository(), + ), + child: PassDetailsView(bookingId: bookingId), ); }, ); diff --git a/lib/core/route_constants.dart b/lib/core/route_constants.dart index 0d8c270..30c1a09 100644 --- a/lib/core/route_constants.dart +++ b/lib/core/route_constants.dart @@ -9,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"; @@ -27,7 +28,8 @@ class RouteConstants { static const String magicItineraryEmptyScreen = '/magicItineraryEmptyScreen'; static const String itineraryCreationStart = '/itineraryCreationStart'; static const String itineraryCreation = '/itineraryCreation'; - static const String magicItineraryFilledScreen = "/magicItineraryFilledScreen"; + static const String magicItineraryFilledScreen = + "/magicItineraryFilledScreen"; /**************************** ESIM Page *****************************************/ @@ -37,12 +39,14 @@ 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 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"; @@ -56,4 +60,5 @@ class RouteConstants { static const String qrPage = '/qrPage'; static const String makeBooking = '/makeBooking'; static const String bookingSuccessful = '/bookingSuccessful'; + static const String editPostCard = '/editPostCard'; } diff --git a/lib/create_account/bloc/create_account_bloc.dart b/lib/create_account/bloc/create_account_bloc.dart index cd62f97..b808abe 100644 --- a/lib/create_account/bloc/create_account_bloc.dart +++ b/lib/create_account/bloc/create_account_bloc.dart @@ -28,14 +28,21 @@ class CreateAccountBloc extends Bloc { 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 { 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( @@ -63,4 +72,4 @@ class CreateAccountBloc extends Bloc { ) { emit(const CreateAccountInitial()); } -} \ No newline at end of file +} diff --git a/lib/create_account/bloc/create_account_event.dart b/lib/create_account/bloc/create_account_event.dart index 5bd6fd7..26a484b 100644 --- a/lib/create_account/bloc/create_account_event.dart +++ b/lib/create_account/bloc/create_account_event.dart @@ -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,9 +40,13 @@ class CreateAccountSubmitted extends CreateAccountEvent { mobileNumber, address1, address2, + city, + state, + country, + postalCode, ]; } class CreateAccountReset extends CreateAccountEvent { const CreateAccountReset(); -} \ No newline at end of file +} diff --git a/lib/create_account/repository/create_account_repository.dart b/lib/create_account/repository/create_account_repository.dart index 738f7d4..2f54d8c 100644 --- a/lib/create_account/repository/create_account_repository.dart +++ b/lib/create_account/repository/create_account_repository.dart @@ -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, }, ); @@ -30,4 +38,4 @@ class CreateAccountRepository { throw Exception('Failed to create account: $e'); } } -} \ No newline at end of file +} diff --git a/lib/create_account/view/create_account_view.dart b/lib/create_account/view/create_account_view.dart index d0a76a8..b68420f 100644 --- a/lib/create_account/view/create_account_view.dart +++ b/lib/create_account/view/create_account_view.dart @@ -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,22 +19,36 @@ 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 createState() => _CreateAccountViewState(); +} + +class _CreateAccountViewState extends State { 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) { + 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')), ); @@ -43,28 +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(), - ), + create: (context) => + CreateAccountBloc(repository: CreateAccountRepository()), child: BlocListener( - listener: (context, state) async { + 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().add(FetchProfileEvent(userId: userId!)); context.read().add(CheckLoginStatusEvent()); + context.read().add(CheckLoginStatus()); + context.read().add(CheckLoginAndFetchItinerary()); + // context.read().add(FetchDraftPostCards()); + context.read().add(RefreshDraftPostCards()); + context.read().add(RefreshOrderPostCards()); + context.read().add(CheckLoginAndFetchPasses()); Navigator.pop(context); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.message))); } else if (state is CreateAccountFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -169,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( + 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( + 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( + 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( + 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( builder: (context, state) { if (state is CreateAccountLoading) { @@ -206,4 +390,4 @@ class CreateAccountView extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/home/model/city_selection_model.dart b/lib/home/model/city_selection_model.dart index 35df4fa..ff56ca3 100644 --- a/lib/home/model/city_selection_model.dart +++ b/lib/home/model/city_selection_model.dart @@ -6,7 +6,8 @@ class CitySelectionResponse { factory CitySelectionResponse.fromJson(Map json) { return CitySelectionResponse( cities: (json['cities'] as List?) - ?.map((city) => CitySelection.fromJson(city as Map)) + ?.map((city) => + CitySelection.fromJson(city as Map)) .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 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) + : null, ); } Map 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 json) { + return CityIcon( + svg: json['svg'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'svg': svg, + }; + } } \ No newline at end of file diff --git a/lib/home/widgets/search_city_bottomsheet.dart b/lib/home/widgets/search_city_bottomsheet.dart index d51cb09..f576ade 100644 --- a/lib/home/widgets/search_city_bottomsheet.dart +++ b/lib/home/widgets/search_city_bottomsheet.dart @@ -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().add(FetchHomeData()); debugPrint("Selected City ID: $cityId"); diff --git a/lib/itinerary_creation/bloc/get_itinerary_bloc.dart b/lib/itinerary_creation/bloc/get_itinerary_bloc.dart new file mode 100644 index 0000000..e91a896 --- /dev/null +++ b/lib/itinerary_creation/bloc/get_itinerary_bloc.dart @@ -0,0 +1,240 @@ +// 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 { +// final ItineraryRepository _repository; +// +// GetItineraryBloc({ItineraryRepository? repository}) +// : _repository = repository ?? ItineraryRepository(), +// super(GetItineraryInitial()) { +// on(_onCheckLoginAndFetch); +// on(_onGetItinerary); +// } +// +// Future _onCheckLoginAndFetch( +// CheckLoginAndFetchItinerary event, +// Emitter 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 _onGetItinerary( +// GetIiterary event, +// Emitter 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'; +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 { + final ItineraryRepository _repository; + + GetItineraryBloc({ItineraryRepository? repository}) + : _repository = repository ?? ItineraryRepository(), + super(GetItineraryInitial()) { + on(_onCheckLoginAndFetch); + on(_onGetItinerary); + } + + Future _onCheckLoginAndFetch( + CheckLoginAndFetchItinerary event, + Emitter emit, + ) async { + try { + emit(GetItineraryLoading()); + + final isLoggedIn = await LocalPreference.getLogin(); + + if (!isLoggedIn) { + emit(GetItineraryNotLoggedIn()); + return; + } + + final response = await _repository.fetchMyItineraries(); + + // 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') + ? e.toString().replaceAll('Exception: ', '') + : "Failed to load itineraries. Please try again.")); + } + } + + Future _onGetItinerary( + GetIiterary event, + Emitter emit, + ) async { + try { + emit(GetItineraryLoading()); + + final response = await _repository.fetchMyItineraries(); + + // 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') + ? e.toString().replaceAll('Exception: ', '') + : "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, + ), + ], + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/itinerary_creation/bloc/get_itinerary_cities_bloc.dart b/lib/itinerary_creation/bloc/get_itinerary_cities_bloc.dart new file mode 100644 index 0000000..e8d6582 --- /dev/null +++ b/lib/itinerary_creation/bloc/get_itinerary_cities_bloc.dart @@ -0,0 +1,26 @@ +import 'dart:developer'; + +import 'package:bloc/bloc.dart'; +import 'package:citycards_customer/itinerary_creation/models/itinerary_city_model.dart'; +import 'package:citycards_customer/itinerary_creation/repository/itinerary_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'get_itinerary_cities_event.dart'; +part 'get_itinerary_cities_state.dart'; + +class GetItineraryCitiesBloc + extends Bloc { + GetItineraryCitiesBloc() : super(GetItineraryCitiesInitial()) { + on((event, emit) async { + try { + log("Getting cities"); + emit(GetItineraryCitiesLoading()); + final data = await ItineraryRepository().fetchItineraryCities(); + emit(GetItineraryCitiesSuccessfully(cities: data)); + } catch (e) { + log("Fetch Itierary - ${e.toString()}"); + emit(GetItineraryCitiesFailed(error: "Something went wrong")); + } + }); + } +} diff --git a/lib/itinerary_creation/bloc/get_itinerary_cities_event.dart b/lib/itinerary_creation/bloc/get_itinerary_cities_event.dart new file mode 100644 index 0000000..3c3628d --- /dev/null +++ b/lib/itinerary_creation/bloc/get_itinerary_cities_event.dart @@ -0,0 +1,10 @@ +part of 'get_itinerary_cities_bloc.dart'; + +abstract class GetItineraryCitiesEvent extends Equatable { + const GetItineraryCitiesEvent(); + + @override + List get props => []; +} + +class GetItineraryCities extends GetItineraryCitiesEvent {} diff --git a/lib/itinerary_creation/bloc/get_itinerary_cities_state.dart b/lib/itinerary_creation/bloc/get_itinerary_cities_state.dart new file mode 100644 index 0000000..5328a26 --- /dev/null +++ b/lib/itinerary_creation/bloc/get_itinerary_cities_state.dart @@ -0,0 +1,22 @@ +part of 'get_itinerary_cities_bloc.dart'; + +abstract class GetItineraryCitiesState extends Equatable { + const GetItineraryCitiesState(); + + @override + List get props => []; +} + +class GetItineraryCitiesInitial extends GetItineraryCitiesState {} + +class GetItineraryCitiesLoading extends GetItineraryCitiesState {} + +class GetItineraryCitiesSuccessfully extends GetItineraryCitiesState { + final List cities; + const GetItineraryCitiesSuccessfully({required this.cities}); +} + +class GetItineraryCitiesFailed extends GetItineraryCitiesState { + final String error; + const GetItineraryCitiesFailed({required this.error}); +} diff --git a/lib/itinerary_creation/bloc/get_itinerary_event.dart b/lib/itinerary_creation/bloc/get_itinerary_event.dart new file mode 100644 index 0000000..33819e2 --- /dev/null +++ b/lib/itinerary_creation/bloc/get_itinerary_event.dart @@ -0,0 +1,12 @@ +part of 'get_itinerary_bloc.dart'; + +abstract class GetItineraryEvent extends Equatable { + const GetItineraryEvent(); + + @override + List get props => []; +} + +class GetIiterary extends GetItineraryEvent {} + +class CheckLoginAndFetchItinerary extends GetItineraryEvent {} \ No newline at end of file diff --git a/lib/itinerary_creation/bloc/get_itinerary_state.dart b/lib/itinerary_creation/bloc/get_itinerary_state.dart new file mode 100644 index 0000000..4f9afc8 --- /dev/null +++ b/lib/itinerary_creation/bloc/get_itinerary_state.dart @@ -0,0 +1,41 @@ +part of 'get_itinerary_bloc.dart'; + +abstract class GetItineraryState extends Equatable { + const GetItineraryState(); + + @override + List get props => []; +} + +final class GetItineraryInitial extends GetItineraryState {} + +class GetItineraryLoading extends GetItineraryState {} + +class GetItineraryNotLoggedIn extends GetItineraryState {} + +class GetItinerarySuccessfully extends GetItineraryState { + final List itineraries; + + const GetItinerarySuccessfully({required this.itineraries}); + + @override + List get props => [itineraries]; +} + +class GetItineraryRequiresPass extends GetItineraryState { + final List itineraries; + + const GetItineraryRequiresPass({required this.itineraries}); + + @override + List get props => [itineraries]; +} + +class GetItineraryFailed extends GetItineraryState { + final String error; + + const GetItineraryFailed({required this.error}); + + @override + List get props => [error]; +} \ No newline at end of file diff --git a/lib/itinerary_creation/bloc/itinerary_detail_bloc.dart b/lib/itinerary_creation/bloc/itinerary_detail_bloc.dart index 82aebaa..3beefd2 100644 --- a/lib/itinerary_creation/bloc/itinerary_detail_bloc.dart +++ b/lib/itinerary_creation/bloc/itinerary_detail_bloc.dart @@ -1,6 +1,9 @@ +import 'package:citycards_customer/itinerary_creation/models/itinerary_city_model.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; +import '../models/current_location_model.dart'; + abstract class ItineraryDetailEvent {} class AddDateToItinerary extends ItineraryDetailEvent { @@ -10,11 +13,17 @@ class AddDateToItinerary extends ItineraryDetailEvent { } class AddCityToItinerary extends ItineraryDetailEvent { - final String city; + final ItineraryCityModel city; AddCityToItinerary(this.city); } +class AddAddressToItinerary extends ItineraryDetailEvent { + final CurrentLocationModel address; + + AddAddressToItinerary(this.address); +} + class AddEnergyToItinerary extends ItineraryDetailEvent { final String energy; @@ -65,7 +74,7 @@ class AddShoppingRating extends ItineraryDetailEvent { class ItineraryDetailState { final String? selectedDate; - final String? selectedCity; + final ItineraryCityModel? selectedCity; final String? selectedEnergy; final String? withKid; final String? selectedDietary; @@ -74,6 +83,7 @@ class ItineraryDetailState { final String? culturalRating; final String? wildLifeRating; final String? shoppingRating; + final CurrentLocationModel? baseAdd; ItineraryDetailState({ this.selectedDate, @@ -86,19 +96,21 @@ class ItineraryDetailState { this.culturalRating, this.wildLifeRating, this.shoppingRating, + this.baseAdd, }); ItineraryDetailState copyWith({ String? selectedDate, - String? selectedCity, + ItineraryCityModel? selectedCity, String? selectedEnergy, String? withKid, - String? selectedDietary, + String? selectedDietary, String? museumRating, String? scenicRating, String? culturalRating, String? wildLifeRating, String? shoppingRating, + CurrentLocationModel? baseAdd, }) { return ItineraryDetailState( selectedDate: selectedDate ?? this.selectedDate, @@ -111,6 +123,7 @@ class ItineraryDetailState { culturalRating: culturalRating ?? this.culturalRating, wildLifeRating: wildLifeRating ?? this.wildLifeRating, shoppingRating: shoppingRating ?? this.shoppingRating, + baseAdd: baseAdd ?? this.baseAdd, ); } } @@ -121,15 +134,6 @@ class AddItineraryDetailBloc : super( ItineraryDetailState( selectedDate: DateFormat('EEEE, MMMM d, yyyy').format(DateTime.now()), - selectedCity: "Paris", - selectedEnergy: "", - withKid: "", - selectedDietary: "", - museumRating: "", - scenicRating: "", - culturalRating: "", - wildLifeRating: "", - shoppingRating: "", ), ) { on((event, emit) { @@ -137,10 +141,13 @@ class AddItineraryDetailBloc }); on((event, emit) { - print("Selected city: ${event.city}"); emit(state.copyWith(selectedCity: event.city)); }); + on((event, emit) { + emit(state.copyWith(baseAdd: event.address)); + }); + on((event, emit) { emit(state.copyWith(selectedEnergy: event.energy)); }); @@ -150,13 +157,6 @@ class AddItineraryDetailBloc }); on((event, emit) { - // final currentSelection = List.from(state.selectedDietary ?? []); - // - // if (currentSelection.contains(event.dietary)) { - // currentSelection.remove(event.dietary); - // } else { - // currentSelection.add(event.dietary); - // } emit(state.copyWith(selectedDietary: event.dietary)); }); diff --git a/lib/itinerary_creation/models/current_location_model.dart b/lib/itinerary_creation/models/current_location_model.dart new file mode 100644 index 0000000..ca7222b --- /dev/null +++ b/lib/itinerary_creation/models/current_location_model.dart @@ -0,0 +1,6 @@ +class CurrentLocationModel { + final String? baseAdd; + final double? lat; + final double? lan; + CurrentLocationModel({this.baseAdd, this.lan, this.lat}); +} diff --git a/lib/itinerary_creation/models/itinerary_city_model.dart b/lib/itinerary_creation/models/itinerary_city_model.dart new file mode 100644 index 0000000..e3ac99d --- /dev/null +++ b/lib/itinerary_creation/models/itinerary_city_model.dart @@ -0,0 +1,57 @@ +class ItineraryCityModel { + int? id; + String? cityName; + String? urlSlug; + int? iconXid; + Icon? icon; + + ItineraryCityModel({ + this.id, + this.cityName, + this.urlSlug, + this.iconXid, + this.icon, + }); + + ItineraryCityModel.fromJson(Map json) { + id = json['id']; + cityName = json['cityName']; + urlSlug = json['urlSlug']; + iconXid = json['iconXid']; + icon = json['icon'] != null ? Icon.fromJson(json['icon']) : null; + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['cityName'] = cityName; + data['urlSlug'] = urlSlug; + data['iconXid'] = iconXid; + if (icon != null) { + data['icon'] = icon!.toJson(); + } + return data; + } +} + +class Icon { + int? id; + String? iconName; + String? iconSvg; + + Icon({this.id, this.iconName, this.iconSvg}); + + Icon.fromJson(Map json) { + id = json['id']; + iconName = json['iconName']; + iconSvg = json['iconSvg']; + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['iconName'] = iconName; + data['iconSvg'] = iconSvg; + return data; + } +} diff --git a/lib/itinerary_creation/models/my_itinerary_model.dart b/lib/itinerary_creation/models/my_itinerary_model.dart new file mode 100644 index 0000000..a2e2c87 --- /dev/null +++ b/lib/itinerary_creation/models/my_itinerary_model.dart @@ -0,0 +1,250 @@ +class MyItineraryResponse { + bool isUnlimitedPass; + List itineraries; + + MyItineraryResponse({ + required this.isUnlimitedPass, + required this.itineraries, + }); + + factory MyItineraryResponse.fromJson(Map? json) { + json ??= {}; + + return MyItineraryResponse( + isUnlimitedPass: json['isUnlimitedPass'] ?? false, + itineraries: json['itineraries'] == null + ? [] + : List>.from(json['itineraries']) + .map((e) => MyItinerary.fromJson(e)) + .toList(), + ); + } + + Map toJson() => { + "isUnlimitedPass": isUnlimitedPass, + "itineraries": itineraries.map((e) => e.toJson()).toList(), + }; +} + +class MyItinerary { + int id; + int userXid; + int cityXid; + String address; + double latitude; + double longitude; + String tripEnergy; + bool travelingWithKids; + List dietaryPreferences; + Preferences preferences; + int totalDays; + String aiModel; + String promptVersion; + bool isActive; + String createdAt; + String updatedAt; + List days; + + MyItinerary({ + required this.id, + required this.userXid, + required this.cityXid, + required this.address, + required this.latitude, + required this.longitude, + required this.tripEnergy, + required this.travelingWithKids, + required this.dietaryPreferences, + required this.preferences, + required this.totalDays, + required this.aiModel, + required this.promptVersion, + required this.isActive, + required this.createdAt, + required this.updatedAt, + required this.days, + }); + + factory MyItinerary.fromJson(Map? json) { + json ??= {}; + + return MyItinerary( + id: (json['id'] as num?)?.toInt() ?? 0, + userXid: (json['userXid'] as num?)?.toInt() ?? 0, + cityXid: (json['cityXid'] as num?)?.toInt() ?? 0, + address: json['Address']?.toString() ?? "", + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + tripEnergy: json['tripEnergy']?.toString() ?? "", + travelingWithKids: json['travelingWithKids'] ?? false, + dietaryPreferences: json['dietaryPreferences'] == null + ? [] + : List.from(json['dietaryPreferences']), + preferences: Preferences.fromJson(json['preferences']), + totalDays: (json['totalDays'] as num?)?.toInt() ?? 0, + aiModel: json['aiModel']?.toString() ?? "", + promptVersion: json['promptVersion']?.toString() ?? "", + isActive: json['isActive'] ?? false, + createdAt: json['createdAt']?.toString() ?? "", + updatedAt: json['updatedAt']?.toString() ?? "", + days: json['days'] == null + ? [] + : List>.from(json['days']) + .map((e) => ItineraryDay.fromJson(e)) + .toList(), + ); + } + + Map toJson() => { + "id": id, + "userXid": userXid, + "cityXid": cityXid, + "Address": address, + "latitude": latitude, + "longitude": longitude, + "tripEnergy": tripEnergy, + "travelingWithKids": travelingWithKids, + "dietaryPreferences": dietaryPreferences, + "preferences": preferences.toJson(), + "totalDays": totalDays, + "aiModel": aiModel, + "promptVersion": promptVersion, + "isActive": isActive, + "createdAt": createdAt, + "updatedAt": updatedAt, + "days": days.map((e) => e.toJson()).toList(), + }; +} + +class Preferences { + int shopping; + int wildlife; + int landmarks; + int scenicViews; + int artAndMuseums; + + Preferences({ + required this.shopping, + required this.wildlife, + required this.landmarks, + required this.scenicViews, + required this.artAndMuseums, + }); + + factory Preferences.fromJson(Map? json) { + json ??= {}; + + return Preferences( + shopping: (json['shopping'] as num?)?.toInt() ?? 0, + wildlife: (json['wildlife'] as num?)?.toInt() ?? 0, + landmarks: (json['landmarks'] as num?)?.toInt() ?? 0, + scenicViews: (json['scenicViews'] as num?)?.toInt() ?? 0, + artAndMuseums: (json['artAndMuseums'] as num?)?.toInt() ?? 0, + ); + } + + Map toJson() => { + "shopping": shopping, + "wildlife": wildlife, + "landmarks": landmarks, + "scenicViews": scenicViews, + "artAndMuseums": artAndMuseums, + }; +} + +class ItineraryDay { + int id; + int itineraryXid; + int dayNumber; + String title; + String summary; + List items; + + ItineraryDay({ + required this.id, + required this.itineraryXid, + required this.dayNumber, + required this.title, + required this.summary, + required this.items, + }); + + factory ItineraryDay.fromJson(Map? json) { + json ??= {}; + + return ItineraryDay( + id: (json['id'] as num?)?.toInt() ?? 0, + itineraryXid: (json['itineraryXid'] as num?)?.toInt() ?? 0, + dayNumber: (json['dayNumber'] as num?)?.toInt() ?? 0, + title: json['title']?.toString() ?? "", + summary: json['summary']?.toString() ?? "", + items: json['items'] == null + ? [] + : List>.from(json['items']) + .map((e) => DayItem.fromJson(e)) + .toList(), + ); + } + + Map toJson() => { + "id": id, + "itineraryXid": itineraryXid, + "dayNumber": dayNumber, + "title": title, + "summary": summary, + "items": items.map((e) => e.toJson()).toList(), + }; +} + +class DayItem { + int id; + int itineraryDayXid; + String timeSlot; + String title; + String description; + String locationName; + String imageUrl; + double latitude; + double longitude; + + DayItem({ + required this.id, + required this.itineraryDayXid, + required this.timeSlot, + required this.title, + required this.description, + required this.locationName, + required this.imageUrl, + required this.latitude, + required this.longitude, + }); + + factory DayItem.fromJson(Map? json) { + json ??= {}; + + return DayItem( + id: (json['id'] as num?)?.toInt() ?? 0, + itineraryDayXid: + (json['itineraryDayXid'] as num?)?.toInt() ?? 0, + timeSlot: json['timeSlot']?.toString() ?? "", + title: json['title']?.toString() ?? "", + description: json['description']?.toString() ?? "", + locationName: json['locationName']?.toString() ?? "", + imageUrl: json['imageUrl']?.toString() ?? "", + latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0, + longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0, + ); + } + + Map toJson() => { + "id": id, + "itineraryDayXid": itineraryDayXid, + "timeSlot": timeSlot, + "title": title, + "description": description, + "locationName": locationName, + "imageUrl": imageUrl, + "latitude": latitude, + "longitude": longitude, + }; +} diff --git a/lib/itinerary_creation/repository/itinerary_repository.dart b/lib/itinerary_creation/repository/itinerary_repository.dart new file mode 100644 index 0000000..aa44efa --- /dev/null +++ b/lib/itinerary_creation/repository/itinerary_repository.dart @@ -0,0 +1,42 @@ +import 'dart:developer'; + +import 'package:citycards_customer/itinerary_creation/models/itinerary_city_model.dart'; +import 'package:dio/dio.dart'; + +import '../../localPreference/local_preference.dart'; +import '../../networkApiServices/api_urls.dart'; +import '../../networkApiServices/network_api_services.dart'; +import '../models/my_itinerary_model.dart'; + +class ItineraryRepository { + final NetworkApiService _apiService = NetworkApiService(); + + Future fetchMyItineraries() async { + final int cityId = await LocalPreference.getSelectedCityId(); + final response = await _apiService.getApi( + url: '${ApiUrls.myItineraries}/$cityId', // 👈 Make sure this endpoint exists + ); + + /// Because API returns LIST + return MyItineraryResponse.fromJson(response.data); + } + + Future> fetchItineraryCities() async { + try { + final response = await _apiService.getApi( + url: ApiUrls.getItineraryCities, + ); + final List cities = (response.data as List) + .map((e) => ItineraryCityModel.fromJson(e as Map)) + .toList(); + + return cities; + } on DioException catch (e) { + // log("Error logged - ${e.response}"); + throw e.response!.data["message"] ?? "Something went wrong"; + } catch (e, stack) { + log("Error logged - ${stack.toString()}"); + rethrow; + } + } +} diff --git a/lib/itinerary_creation/views/itinerary_creation_steps/city_selection_view.dart b/lib/itinerary_creation/views/itinerary_creation_steps/city_selection_view.dart index be65f52..6705792 100644 --- a/lib/itinerary_creation/views/itinerary_creation_steps/city_selection_view.dart +++ b/lib/itinerary_creation/views/itinerary_creation_steps/city_selection_view.dart @@ -1,47 +1,30 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:citycards_customer/common_packages/custom_filled_button.dart'; -import 'package:citycards_customer/common_packages/custom_search_field.dart'; import 'package:citycards_customer/common_packages/custom_text.dart'; -import 'package:citycards_customer/common_packages/custom_textfield.dart'; +import 'package:citycards_customer/itinerary_creation/bloc/get_itinerary_cities_bloc.dart'; import 'package:citycards_customer/itinerary_creation/bloc/itinerary_detail_bloc.dart'; import 'package:citycards_customer/itinerary_creation/bloc/itinerary_steps_selection_bloc.dart'; +import 'package:citycards_customer/networkApiServices/api_urls.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -class MenuItem { - final int id; - final String label; - final String flag; +class CitySelectionView extends StatefulWidget { + const CitySelectionView({super.key}); - MenuItem(this.id, this.label, this.flag); + @override + State createState() => _CitySelectionViewState(); } -List menuItems = [ - MenuItem(1, 'Paris', "đŸ‡Ģ🇷"), - MenuItem(2, 'Tokyo', "đŸ‡¯đŸ‡ĩ"), - MenuItem(3, 'New York', "đŸ‡ē🇸"), - MenuItem(4, 'London', "đŸ‡Ŧ🇧"), - MenuItem(5, 'Barcelona', "đŸ‡Ē🇸"), - MenuItem(6, 'Dubai', "đŸ‡ĻđŸ‡Ē"), - MenuItem(7, 'Rome', "🇮🇹"), - MenuItem(8, 'Bangkok', "🇹🇭"), -]; - -class CitySelectionView extends StatelessWidget { - CitySelectionView({super.key}); - - final List> cityList = [ - {"flag": "đŸ‡Ģ🇷", "city": "Paris"}, - {"flag": "đŸ‡¯đŸ‡ĩ", "city": "Tokyo"}, - {"flag": "đŸ‡ē🇸", "city": "New York"}, - {"flag": "đŸ‡Ŧ🇧", "city": "London"}, - {"flag": "đŸ‡Ē🇸", "city": "Barcelona"}, - {"flag": "đŸ‡ĻđŸ‡Ē", "city": "Dubai"}, - {"flag": "🇮🇹", "city": "Rome"}, - {"flag": "🇹🇭", "city": "Bangkok"}, - ]; - +class _CitySelectionViewState extends State { final TextEditingController cityController = TextEditingController(); + final GetItineraryCitiesBloc getItineraryCitiesBloc = + GetItineraryCitiesBloc(); + @override + void initState() { + getItineraryCitiesBloc.add(GetItineraryCities()); + super.initState(); + } @override Widget build(BuildContext context) { @@ -60,89 +43,6 @@ class CitySelectionView extends StatelessWidget { ), SizedBox(height: 32.h), - Container( - height: 56.h, - padding: EdgeInsets.only(left: 20.w), - decoration: BoxDecoration( - border: Border.all(color: Color(0xFFF95F62)), - borderRadius: BorderRadius.circular(28), - ), - child: Row( - children: [ - Image.asset("assets/icons/location.png", scale: 4), - Expanded( - child: SizedBox( - child: - BlocBuilder< - AddItineraryDetailBloc, - ItineraryDetailState - >( - builder: (context, state) { - final selectedMenuItem = menuItems.firstWhere( - (menu) => menu.label == state.selectedCity, - orElse: () => - menuItems.first, // fallback if not found - ); - return DropdownMenu( - controller: cityController, - initialSelection: selectedMenuItem, - width: double.infinity, - hintText: "Select City", - requestFocusOnTap: true, - enableFilter: true, - showTrailingIcon: false, - onSelected: (MenuItem? menu) { - context.read().add( - AddCityToItinerary(menu!.label), - ); - }, - inputDecorationTheme: InputDecorationTheme( - contentPadding: EdgeInsets.symmetric( - vertical: 6.h, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: const BorderSide( - color: Colors.transparent, - ), - ), - ), - - menuStyle: MenuStyle( - backgroundColor: WidgetStateProperty.all( - Colors.white, - ), - maximumSize: WidgetStateProperty.all( - Size.infinite, - ), - ), - dropdownMenuEntries: menuItems - .map>(( - MenuItem menu, - ) { - return DropdownMenuEntry( - value: menu, - label: menu.label, - leadingIcon: CustomText(text: menu.flag), - ); - }) - .toList(), - ); - }, - ), - ), - ), - ], - ), - ), - SizedBox(height: 16.h), Align( alignment: Alignment.topLeft, @@ -154,57 +54,86 @@ class CitySelectionView extends StatelessWidget { ), ), SizedBox(height: 10.h), - SizedBox( - height: 175.h, - child: BlocBuilder( - builder: (context, state) { - return GridView.builder( - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 16.h, - crossAxisSpacing: 16.w, - ), - itemCount: cityList.length, - itemBuilder: (context, index) { - final item = cityList[index]; - final isSelected = item['city'] == state.selectedCity; - return GestureDetector( - onTap: () { - context.read().add( - AddCityToItinerary(item['city'] ?? ""), - ); - }, - child: Container( - height: 78.h, - width: 76.w, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? Color(0xFFF95F62) - : Colors.transparent, - ), + BlocBuilder( + bloc: getItineraryCitiesBloc, + builder: (ctx, state1) { + if (state1 is GetItineraryCitiesLoading) { + return Center(child: CircularProgressIndicator()); + } else if (state1 is GetItineraryCitiesFailed) { + return Center(child: Text(state1.error)); + } else if (state1 is GetItineraryCitiesSuccessfully && + state1.cities.isEmpty) { + return Center(child: Text("Data not found")); + } else if (state1 is GetItineraryCitiesSuccessfully) { + return SizedBox( + height: 175.h, + child: BlocBuilder( + builder: (context, state) { + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 16.h, + crossAxisSpacing: 16.w, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CustomText(text: item['flag'] ?? ""), - SizedBox(height: 4.h), - CustomText( - text: item['city'] ?? "", - size: 12.sp, - color: Color(0xFF364153), + itemCount: state1.cities.length, + itemBuilder: (context, index) { + final item = state1.cities[index]; + final isSelected = item == state.selectedCity; + return GestureDetector( + onTap: () { + context.read().add( + AddCityToItinerary(item), + ); + }, + child: Container( + height: 78.h, + width: 76.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? Color(0xFFF95F62) + : Colors.transparent, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CachedNetworkImage( + imageUrl: + "${ApiUrls.baseUrl}${item.icon!.iconSvg!}", + width: 20, + height: 20, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 20, + height: 20, + color: Colors.grey.shade50, + ), + errorWidget: (context, url, error) => + const Icon(Icons.flag, size: 20), + ), + SizedBox(height: 4.h), + CustomText( + text: item.cityName ?? "", + size: 12.sp, + color: Color(0xFF364153), + ), + ], + ), ), - ], - ), - ), - ); - }, + ); + }, + ); + }, + ), ); - }, - ), + } + + return Container(); + }, ), SizedBox(height: 40.h), CustomFilledButton( diff --git a/lib/itinerary_creation/views/itinerary_creation_steps/current_location_selection.dart b/lib/itinerary_creation/views/itinerary_creation_steps/current_location_selection.dart index 6280e50..2fa8d6e 100644 --- a/lib/itinerary_creation/views/itinerary_creation_steps/current_location_selection.dart +++ b/lib/itinerary_creation/views/itinerary_creation_steps/current_location_selection.dart @@ -1,11 +1,16 @@ 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/itinerary_steps_selection_bloc.dart'; +import 'package:citycards_customer/itinerary_creation/models/current_location_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:geocoding/geocoding.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../bloc/itinerary_detail_bloc.dart'; class CurrentLocationSelection extends StatefulWidget { const CurrentLocationSelection({super.key}); @@ -18,26 +23,72 @@ class CurrentLocationSelection extends StatefulWidget { class _CurrentLocationSelectionState extends State { final TextEditingController _controller = TextEditingController(); LatLng? _currentLatLng; + bool loading = false; Future _getCurrentLocation() async { - LocationPermission permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Location permission denied')), + try { + setState(() { + loading = true; + }); + LocationPermission permission = await Geolocator.requestPermission(); + + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Location permission denied')), + ); + return; + } + + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, ); - return; + + final lat = position.latitude; + final lng = position.longitude; + + setState(() { + _currentLatLng = LatLng(lat, lng); + }); + + await _getAddressFromLatLng(lat, lng); + setState(() { + loading = false; + }); + } catch (e) { + setState(() { + loading = false; + }); + } finally { + setState(() { + loading = false; + }); } + } - final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); + Future _getAddressFromLatLng(double lat, double lng) async { + try { + final placemarks = await placemarkFromCoordinates(lat, lng); - setState(() { - _currentLatLng = LatLng(position.latitude, position.longitude); - _controller.text = - "Lat: ${position.latitude.toStringAsFixed(5)}, Lng: ${position.longitude.toStringAsFixed(5)}"; - }); + if (placemarks.isNotEmpty) { + final place = placemarks.first; + + final address = [ + place.street, + place.subLocality, + place.locality, + place.administrativeArea, + place.postalCode, + place.country, + ].where((e) => e != null && e.isNotEmpty).join(', '); + + setState(() { + _controller.text = address; + }); + } + } catch (e) { + debugPrint("Reverse geocoding error: $e"); + } } @override @@ -98,32 +149,45 @@ class _CurrentLocationSelectionState extends State { child: SizedBox( height: 250.h, width: double.infinity, - - child: Image.asset( - "assets/images/attra_detail_map.png", - fit: BoxFit.cover, - height: 236.h, - ), - // child: GoogleMap( - // initialCameraPosition: CameraPosition( - // target: _currentLatLng!, - // zoom: 15, - // ), - // markers: { - // Marker( - // markerId: const MarkerId("currentLocation"), - // position: _currentLatLng!, - // ), - // }, - // myLocationEnabled: true, - // myLocationButtonEnabled: false, - // ), + child: loading == true + ? Center( + child: CircularProgressIndicator( + color: Color(0xFFF95F62), + ), + ) + : FlutterMap( + options: MapOptions( + initialCenter: _currentLatLng!, + initialZoom: 15, + ), + children: [ + TileLayer( + urlTemplate: + "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + subdomains: const ['a', 'b', 'c'], + userAgentPackageName: + 'com.citycards.customer', + ), + MarkerLayer( + markers: [ + Marker( + point: _currentLatLng!, + width: 40, + height: 40, + child: const Icon( + Icons.location_pin, + color: Colors.red, + size: 40, + ), + ), + ], + ), + ], + ), ), ) : GestureDetector( - onTap: () { - _getCurrentLocation(); - }, + onTap: _getCurrentLocation, child: Container( height: 46.h, padding: EdgeInsets.symmetric(horizontal: 12.w), @@ -155,6 +219,15 @@ class _CurrentLocationSelectionState extends State { // --- Continue button --- CustomFilledButton( onTap: () { + context.read().add( + AddAddressToItinerary( + CurrentLocationModel( + baseAdd: _controller.text, + lan: _currentLatLng?.latitude, + lat: _currentLatLng?.latitude, + ), + ), + ); context.read().add( ItineraryStepNavigationNextEvent(), ); diff --git a/lib/itinerary_creation/views/itinerary_creation_steps/date_selection_view.dart b/lib/itinerary_creation/views/itinerary_creation_steps/date_selection_view.dart index 7fec6df..39031fb 100644 --- a/lib/itinerary_creation/views/itinerary_creation_steps/date_selection_view.dart +++ b/lib/itinerary_creation/views/itinerary_creation_steps/date_selection_view.dart @@ -27,35 +27,35 @@ class DateSelectionView extends StatelessWidget { ), SizedBox(height: 32.h), - Container( - height: 90.h, - padding: EdgeInsets.symmetric(horizontal: 20.w), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(28), - border: Border.all(color: Color(0xFFF95F62), width: 1.1.w), - ), - child: Row( - children: [ - GestureDetector( - onTap: () { - _pickDate(context); - }, - child: Image.asset("assets/icons/calender.png", scale: 4), - ), - SizedBox(width: 16.w), - BlocBuilder( - builder: (context, state) { - return CustomText( - text: state.selectedDate ?? "", - size: 14.sp, - color: Color(0xFF101828), - ); - }, - ), - const Spacer(), - Icon(Icons.check_circle, color: Color(0xFFF95F62)), - ], + GestureDetector( + onTap: () { + _pickDate(context); + }, + child: Container( + height: 90.h, + padding: EdgeInsets.symmetric(horizontal: 20.w), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: Color(0xFFF95F62), width: 1.1.w), + ), + child: Row( + children: [ + Image.asset("assets/icons/calender.png", scale: 4), + SizedBox(width: 16.w), + BlocBuilder( + builder: (context, state) { + return CustomText( + text: state.selectedDate ?? "", + size: 14.sp, + color: Color(0xFF101828), + ); + }, + ), + const Spacer(), + Icon(Icons.check_circle, color: Color(0xFFF95F62)), + ], + ), ), ), SizedBox(height: 32.h), diff --git a/lib/itinerary_creation/views/itinerary_creation_steps/itinerary_completion_view.dart b/lib/itinerary_creation/views/itinerary_creation_steps/itinerary_completion_view.dart index cbec5be..20ce599 100644 --- a/lib/itinerary_creation/views/itinerary_creation_steps/itinerary_completion_view.dart +++ b/lib/itinerary_creation/views/itinerary_creation_steps/itinerary_completion_view.dart @@ -69,7 +69,7 @@ class ItineraryCompletionView extends StatelessWidget { ), _buildProfileRow( "City", - state.selectedCity ?? "", + state.selectedCity!.cityName ?? "", ), _buildProfileRow( "Energy", diff --git a/lib/itinerary_creation/views/itinerary_creation_view.dart b/lib/itinerary_creation/views/itinerary_creation_view.dart index 75bd61d..01f9438 100644 --- a/lib/itinerary_creation/views/itinerary_creation_view.dart +++ b/lib/itinerary_creation/views/itinerary_creation_view.dart @@ -1,4 +1,4 @@ - +import 'package:citycards_customer/itinerary_creation/bloc/get_itinerary_cities_bloc.dart'; import 'package:citycards_customer/itinerary_creation/bloc/itinerary_steps_selection_bloc.dart'; import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_steps/current_location_selection.dart'; import 'package:flutter/material.dart'; @@ -105,7 +105,10 @@ class _ItineraryCreationPageState extends State { children: [ DateSelectionView(), CurrentLocationSelection(), - CitySelectionView(), + BlocProvider( + create: (context) => GetItineraryCitiesBloc(), + child: CitySelectionView(), + ), EnergySelectionView(), KidsSelectionView(), DietarySelectionView(), diff --git a/lib/itinerary_creation/views/magic_itinerary_view.dart b/lib/itinerary_creation/views/magic_itinerary_view.dart index 4732376..9e255a5 100644 --- a/lib/itinerary_creation/views/magic_itinerary_view.dart +++ b/lib/itinerary_creation/views/magic_itinerary_view.dart @@ -3,11 +3,15 @@ import 'package:citycards_customer/common_packages/custom_filled_button.dart'; import 'package:citycards_customer/common_packages/custom_text.dart'; import 'package:citycards_customer/core/route_constants.dart'; import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_start_view.dart'; +import 'package:citycards_customer/itinerary_creation/bloc/get_itinerary_bloc.dart'; +import 'package:citycards_customer/itinerary_creation/models/my_itinerary_model.dart'; 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 '../../localPreference/local_preference.dart'; +import '../../common_bloc/bottom_navigation_bloc.dart'; import '../../login/view/login_email_bottomsheet.dart'; +import 'package:intl/intl.dart'; class MagicItineraryView extends StatefulWidget { const MagicItineraryView({super.key}); @@ -17,32 +21,19 @@ class MagicItineraryView extends StatefulWidget { } class _MagicItineraryViewState extends State { - bool isLoggedIn = false; - bool isLoading = true; - @override void initState() { super.initState(); - _checkLoginStatus(); - } - - Future _checkLoginStatus() async { - // final loginStatus = await LocalPreference.getLogin(); - final loginStatus = true; - setState(() { - isLoggedIn = loginStatus; - isLoading = false; - }); + // Trigger login check and fetch on init + context.read().add(CheckLoginAndFetchItinerary()); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Color(0xFFFFF5F5), + backgroundColor: Colors.white, body: SafeArea( - child: isLoading - ? Center(child: CircularProgressIndicator()) - : Padding( + child: Padding( padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), child: SingleChildScrollView( child: Column( @@ -50,52 +41,91 @@ class _MagicItineraryViewState extends State { CommonAppBar( isWhiteLogo: false, isProfilePage: false, - showDivider: false, + showDivider: true, ), SizedBox(height: 24.h), - - // Show different UI based on login status - if (isLoggedIn) ...[ - ItineraryFilledCard(), - SizedBox(height: 32.h), - CustomPaint( - painter: DottedBorderPainter(), - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric(vertical: 24.h), - decoration: BoxDecoration( - color: Color(0xFFF95F62).withOpacity(0.25), - borderRadius: BorderRadius.circular(12.sp), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + // BLoC Builder for all states + BlocBuilder( + builder: (context, state) { + if (state is GetItineraryLoading) { + return Center( + child: Padding( + padding: EdgeInsets.only(top: 100.h), + child: CircularProgressIndicator(), + ), + ); + } 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(); + } + return Column( children: [ - CustomText( - text: "Plan your next adventure", - color: Color(0xFF656565), - size: 14.sp, - ), - SizedBox(height: 16.h), - CustomFilledButton( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ItineraryCreationStartPage(), + ...state.itineraries.map((itinerary) { + return Column( + children: [ + ItineraryFilledCard( + itinerary: itinerary, ), - ); - }, - label: "Create My Itinerary", - showArrow: true, + SizedBox(height: 16.h), + ], + ); + }).toList(), + SizedBox(height: 16.h), + CustomPaint( + painter: DottedBorderPainter(), + child: Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 24.h), + decoration: BoxDecoration( + color: Color(0xFFF95F62).withOpacity(0.1), + borderRadius: BorderRadius.circular(12.sp), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CustomText( + text: "Plan your next adventure", + color: Color(0xFF656565), + size: 14.sp, + ), + SizedBox(height: 16.h), + CustomFilledButton( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ItineraryCreationStartPage(), + ), + ); + }, + label: "Create My Itinerary", + showArrow: true, + ), + ], + ), + ), ), ], - ), - ), - ), - ] else ...[ - EmptyItineraryView(), - ], + ); + } else if (state is GetItineraryFailed) { + return ErrorItineraryView( + error: state.error, + onRetry: () { + context + .read() + .add(CheckLoginAndFetchItinerary()); + }, + ); + } + // Initial state + return SizedBox.shrink(); + }, + ), ], ), ), @@ -105,8 +135,8 @@ class _MagicItineraryViewState extends State { } } -class EmptyItineraryView extends StatelessWidget { - const EmptyItineraryView({super.key}); +class NotLoggedInItineraryView extends StatelessWidget { + const NotLoggedInItineraryView({super.key}); @override Widget build(BuildContext context) { @@ -116,7 +146,7 @@ class EmptyItineraryView extends StatelessWidget { // Illustration image - replace with your asset path Image.asset( - "assets/images/not_login.png", // Replace with your actual asset path + "assets/images/not_login.png", height: 300.h, fit: BoxFit.contain, ), @@ -151,9 +181,7 @@ class EmptyItineraryView extends StatelessWidget { context: context, isScrollControlled: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.r), - ), + borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)), ), builder: (_) => const LoginEmailBottomsheet(), ); @@ -166,11 +194,215 @@ class EmptyItineraryView extends StatelessWidget { } } -class ItineraryFilledCard extends StatelessWidget { - const ItineraryFilledCard({super.key}); +class RequiresUnlimitedPassView extends StatelessWidget { + const RequiresUnlimitedPassView({super.key}); @override Widget build(BuildContext context) { + return Column( + children: [ + SizedBox(height: 40.h), + + // 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: "You do not possess an Unlimited Pass! 😔", + size: 18.sp, + weight: FontWeight.w600, + textAlign: TextAlign.center, + ), + + SizedBox(height: 12.h), + + Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: CustomText( + text: "Get your Unlimited Pass and create a custom itinerary!", + size: 14.sp, + color: Color(0xFF656565), + textAlign: TextAlign.center, + ), + ), + + SizedBox(height: 32.h), + + CustomFilledButton( + onTap: () { + context.read().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 Don’t 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( + context, + MaterialPageRoute( + builder: (context) => ItineraryCreationStartPage(), + ), + ); + }, + label: "Create My Itinerary", + showArrow: true, + ), + + SizedBox(height: 40.h), + ], + ), + ); + } +} + +class ErrorItineraryView extends StatelessWidget { + final String error; + final VoidCallback onRetry; + + const ErrorItineraryView({ + super.key, + required this.error, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox(height: 40.h), + + Icon( + Icons.error_outline, + size: 120.sp, + color: Colors.red.withOpacity(0.3), + ), + + SizedBox(height: 32.h), + + CustomText( + text: "Oops! Something went wrong", + size: 18.sp, + weight: FontWeight.w600, + textAlign: TextAlign.center, + ), + + SizedBox(height: 12.h), + + Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: CustomText( + text: error, + size: 14.sp, + color: Color(0xFF656565), + textAlign: TextAlign.center, + ), + ), + + SizedBox(height: 32.h), + + CustomFilledButton( + onTap: onRetry, + label: "Try Again", + showArrow: false, + ), + ], + ); + } +} + +class ItineraryFilledCard extends StatelessWidget { + final MyItinerary itinerary; + + const ItineraryFilledCard({ + super.key, + required this.itinerary, + }); + + String _formatDate(String dateString) { + try { + final date = DateTime.parse(dateString); + return DateFormat('M/d/yyyy').format(date); + } catch (e) { + return dateString; + } + } + + int _getTotalAttractions() { + int total = 0; + for (var day in itinerary.days) { + total += day.items.length; + } + return total; + } + + String _getCityName() { + // You might want to fetch city name from cityXid or use address + // For now, extracting from address + if (itinerary.address.isNotEmpty) { + return itinerary.address.split(',').last.trim(); + } + return "Unknown City"; + } + + @override + Widget build(BuildContext context) { + final totalAttractions = _getTotalAttractions(); + final cityName = _getCityName(); + return Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h), decoration: BoxDecoration( @@ -183,19 +415,23 @@ class ItineraryFilledCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - CustomText( - text: "Melbourne Unlimited Card", - size: 16.sp, - weight: FontWeight.w500, + Expanded( + child: CustomText( + text: "$cityName Travel Plan", + size: 16.sp, + weight: FontWeight.w500, + ), ), Container( padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 2.h), decoration: BoxDecoration( - color: Color(0xFF439F6E), + color: itinerary.isActive + ? Color(0xFF439F6E) + : Colors.grey.shade400, borderRadius: BorderRadius.circular(100.r), ), child: CustomText( - text: "Active", + text: itinerary.isActive ? "Active" : "Inactive", size: 11.sp, color: Colors.white, ), @@ -204,7 +440,7 @@ class ItineraryFilledCard extends StatelessWidget { ), SizedBox(height: 4.h), CustomText( - text: "Melbourne", + text: cityName, size: 12.sp, color: Colors.black.withOpacity(0.4), ), @@ -213,7 +449,11 @@ class ItineraryFilledCard extends StatelessWidget { children: [ Image.asset("assets/icons/calender_filled.png", width: 16.sp), SizedBox(width: 4.w), - CustomText(text: "7 days", color: Color(0xFF8E8E8E), size: 12.sp), + CustomText( + text: "${itinerary.totalDays} days", + color: Color(0xFF8E8E8E), + size: 12.sp, + ), ], ), SizedBox(height: 8.h), @@ -226,7 +466,7 @@ class ItineraryFilledCard extends StatelessWidget { ), SizedBox(width: 4.w), CustomText( - text: "6 attractions", + text: "$totalAttractions attractions", color: Color(0xFF8E8E8E), size: 12.sp, ), @@ -238,17 +478,34 @@ class ItineraryFilledCard extends StatelessWidget { Icon(Icons.watch_later, color: Color(0xFF8E8E8E), size: 16.sp), SizedBox(width: 4.w), CustomText( - text: "Created 1/15/2024", + text: "Created ${_formatDate(itinerary.createdAt)}", color: Color(0xFF8E8E8E), size: 12.sp, ), ], ), + // if (itinerary.travelingWithKids) ...[ + // SizedBox(height: 8.h), + // Row( + // children: [ + // Icon(Icons.family_restroom, + // color: Color(0xFF8E8E8E), size: 16.sp), + // SizedBox(width: 4.w), + // CustomText( + // text: "Family Friendly", + // color: Color(0xFF8E8E8E), + // size: 12.sp, + // ), + // ], + // ), + // ], SizedBox(height: 12.h), InkWell( onTap: () { - Navigator.of(context) - .pushReplacementNamed(RouteConstants.yourItinerary); + Navigator.of(context).pushReplacementNamed( + RouteConstants.yourItinerary, + arguments: itinerary, + ); }, child: Container( height: 43.h, diff --git a/lib/localPreference/local_database.dart b/lib/localPreference/local_database.dart index 4380d0f..b248f02 100644 --- a/lib/localPreference/local_database.dart +++ b/lib/localPreference/local_database.dart @@ -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 ( @@ -91,6 +83,15 @@ class LocalDatabase { ) '''); + /// CITY TABLE (with city_logo field) + await db.execute(''' + CREATE TABLE selected_city ( + id INTEGER PRIMARY KEY, + city_id INTEGER, + city_logo TEXT + ) +'''); + }, ); } diff --git a/lib/localPreference/local_preference.dart b/lib/localPreference/local_preference.dart index 2e20eba..5b40d34 100644 --- a/lib/localPreference/local_preference.dart +++ b/lib/localPreference/local_preference.dart @@ -395,6 +395,32 @@ class LocalPreference { } } + static Future setSelectedCityLogo(String logoUrl) async { + final db = await LocalDatabase().database; + + await db.update( + 'selected_city', + {'city_logo': logoUrl}, + where: 'id = ?', + whereArgs: [1], + ); + } + + static Future getSelectedCityLogo() async { + final db = await LocalDatabase().database; + + final result = await db.query( + 'selected_city', + where: 'id = ?', + whereArgs: [1], + ); + + if (result.isNotEmpty) { + return result.first['city_logo'] as String?; + } + return null; + } + static Future clearUserDetails() async { final db = await LocalDatabase().database; diff --git a/lib/login/view/login_email_bottomsheet.dart b/lib/login/view/login_email_bottomsheet.dart index 57cbecf..43675a9 100644 --- a/lib/login/view/login_email_bottomsheet.dart +++ b/lib/login/view/login_email_bottomsheet.dart @@ -52,6 +52,7 @@ class _LoginEmailBottomsheetState extends State { ), ); } else if (state is LoginError) { + Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.errorMessage), diff --git a/lib/login/view/verify_otp_bottomsheet.dart b/lib/login/view/verify_otp_bottomsheet.dart index 3c8146d..e93986c 100644 --- a/lib/login/view/verify_otp_bottomsheet.dart +++ b/lib/login/view/verify_otp_bottomsheet.dart @@ -1,5 +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'; @@ -42,10 +45,12 @@ class _VerifyOtpBottomsheetState extends State { context.read().add(FetchProfileEvent(userId: userId!)); context.read().add(CheckLoginStatusEvent()); context.read().add(CheckLoginStatus()); - context.read().add(FetchDraftPostCards()); + context.read().add(CheckLoginAndFetchItinerary()); + // context.read().add(FetchDraftPostCards()); context.read().add(RefreshDraftPostCards()); context.read().add(RefreshOrderPostCards()); - context.read().add(FetchOrderPostCards()); + // context.read().add(FetchOrderPostCards()); + context.read().add(CheckLoginAndFetchPasses()); // User exists - navigate to home/dashboard // Navigator.of(context).pushReplacementNamed(RouteConstants.home); ScaffoldMessenger.of(context).showSnackBar( @@ -56,7 +61,7 @@ class _VerifyOtpBottomsheetState extends State { ); } 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'), @@ -72,6 +77,7 @@ class _VerifyOtpBottomsheetState extends State { ), ); } else if (state is VerifyOtpError) { + Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.errorMessage), diff --git a/lib/main.dart b/lib/main.dart index 0422f2e..a125755 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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,15 +9,21 @@ 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'; import 'home/repository/first_time_user_home_repository.dart'; import 'home/repository/home_repository.dart'; +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'; @@ -56,6 +63,12 @@ class MyApp extends StatelessWidget { BlocProvider( create: (_) => MyPassBloc()..add(LoadMyPasses()), ), + BlocProvider( + create: (_) => MyPassesBloc(MyPassesRepository()), + ), + BlocProvider( + create: (_) => MyPassCartBloc(repository: MyPassCartRepository()), + ), BlocProvider( create: (context) => FirstTimeUserHomeBloc( FirstTimeUserHomeRepository(), @@ -81,8 +94,13 @@ class MyApp extends StatelessWidget { repository: MyPostCardsRepository(), ), ), + BlocProvider( + create: (context) => GetItineraryBloc(), + child: MagicItineraryView(), + ) ], child: MaterialApp( + scaffoldMessengerKey: GlobalKeys.scaffoldMessengerKey, onGenerateRoute: _appRouter.onGenerateRoute, initialRoute: RouteConstants.splash, debugShowCheckedModeBanner: false, diff --git a/lib/my_pass/blocs/myPasses/my_passes_bloc.dart b/lib/my_pass/blocs/myPasses/my_passes_bloc.dart new file mode 100644 index 0000000..27441da --- /dev/null +++ b/lib/my_pass/blocs/myPasses/my_passes_bloc.dart @@ -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 { + final MyPassesRepository repository; + + MyPassesBloc(this.repository) : super(MyPassesInitial()) { + on(_onCheckLoginAndFetchPasses); + on(_onFetchMyPasses); + on(_onRefreshMyPasses); + } + + Future _onCheckLoginAndFetchPasses( + CheckLoginAndFetchPasses event, + Emitter 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 _onFetchMyPasses( + FetchMyPasses event, + Emitter 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 _onRefreshMyPasses( + RefreshMyPasses event, + Emitter 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.")); + } + } +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPasses/my_passes_event.dart b/lib/my_pass/blocs/myPasses/my_passes_event.dart new file mode 100644 index 0000000..04af4cd --- /dev/null +++ b/lib/my_pass/blocs/myPasses/my_passes_event.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; + +abstract class MyPassesEvent extends Equatable { + const MyPassesEvent(); + + @override + List 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 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 get props => [cardMode, sort]; +} + +/// Refresh Event +class RefreshMyPasses extends MyPassesEvent { + final String cardMode; + final String sort; + + const RefreshMyPasses({ + this.cardMode = "", + this.sort = "", + }); + + @override + List get props => [cardMode, sort]; +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPasses/my_passes_state.dart b/lib/my_pass/blocs/myPasses/my_passes_state.dart new file mode 100644 index 0000000..e660aee --- /dev/null +++ b/lib/my_pass/blocs/myPasses/my_passes_state.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; + +import '../../models/my_passes_model.dart'; + +abstract class MyPassesState extends Equatable { + const MyPassesState(); + + @override + List 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 get props => [passes]; +} + +/// Error State +class MyPassesError extends MyPassesState { + final String message; + + const MyPassesError(this.message); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart b/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart new file mode 100644 index 0000000..5fbf157 --- /dev/null +++ b/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart @@ -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 { + final MyPassesAttractionsRepository repository; + + MyPassesAttractionsBloc({required this.repository}) + : super(MyPassesAttractionsInitial()) { + on(_onFetchMyPassesAttractionsByCategory); + on(_onSearchMyPassesAttractions); + } + + Future _onFetchMyPassesAttractionsByCategory( + FetchMyPassesAttractionsByCategory event, + Emitter 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 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, + ), + ); + } + } +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_event.dart b/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_event.dart new file mode 100644 index 0000000..8692230 --- /dev/null +++ b/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_event.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +abstract class MyPassesAttractionsEvent extends Equatable { + const MyPassesAttractionsEvent(); + + @override + List get props => []; +} + +class FetchMyPassesAttractionsByCategory extends MyPassesAttractionsEvent { + final int cityXid; + final int? categoryXid; + + const FetchMyPassesAttractionsByCategory({ + required this.cityXid, + this.categoryXid, + }); + + @override + List get props => [cityXid, categoryXid]; +} + +class SearchMyPassesAttractions extends MyPassesAttractionsEvent { + final String query; + + const SearchMyPassesAttractions(this.query); + + @override + List get props => [query]; +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_state.dart b/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_state.dart new file mode 100644 index 0000000..ca98226 --- /dev/null +++ b/lib/my_pass/blocs/myPassesAttrctions/my_passes_attractions_state.dart @@ -0,0 +1,64 @@ +import 'package:equatable/equatable.dart'; + +import '../../../attractions/models/attraction_model.dart'; + +abstract class MyPassesAttractionsState extends Equatable { + const MyPassesAttractionsState(); + + @override + List get props => []; +} + +class MyPassesAttractionsInitial extends MyPassesAttractionsState {} + +class MyPassesAttractionsLoading extends MyPassesAttractionsState {} + +class MyPassesAttractionsLoaded extends MyPassesAttractionsState { + final List attractions; + final List filteredAttractions; + final List 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? attractions, + List? filteredAttractions, + List? 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 get props => [ + attractions, + filteredAttractions, + categories, + selectedCategoryId, + searchQuery, + ]; +} + +class MyPassesAttractionsError extends MyPassesAttractionsState { + final String message; + + const MyPassesAttractionsError(this.message); + + @override + List get props => [message]; +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPassesDetails/my_passes_details_bloc.dart b/lib/my_pass/blocs/myPassesDetails/my_passes_details_bloc.dart new file mode 100644 index 0000000..15bf417 --- /dev/null +++ b/lib/my_pass/blocs/myPassesDetails/my_passes_details_bloc.dart @@ -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 { + final MyPassesDetailsRepository repository; + + MyPassesDetailsBloc({required this.repository}) + : super(MyPassesDetailsInitial()) { + on(_fetchPassDetails); + } + + Future _fetchPassDetails( + FetchMyPassDetails event, + Emitter emit, + ) async { + emit(MyPassesDetailsLoading()); + + try { + final response = + await repository.fetchPassDetails(passId: event.passId); + + emit(MyPassesDetailsLoaded(data: response)); + } catch (e) { + emit(MyPassesDetailsError(message: e.toString())); + } + } +} diff --git a/lib/my_pass/blocs/myPassesDetails/my_passes_details_event.dart b/lib/my_pass/blocs/myPassesDetails/my_passes_details_event.dart new file mode 100644 index 0000000..62e614c --- /dev/null +++ b/lib/my_pass/blocs/myPassesDetails/my_passes_details_event.dart @@ -0,0 +1,17 @@ +import 'package:equatable/equatable.dart'; + +abstract class MyPassesDetailsEvent extends Equatable { + const MyPassesDetailsEvent(); + + @override + List get props => []; +} + +class FetchMyPassDetails extends MyPassesDetailsEvent { + final int passId; + + const FetchMyPassDetails({required this.passId}); + + @override + List get props => [passId]; +} diff --git a/lib/my_pass/blocs/myPassesDetails/my_passes_details_state.dart b/lib/my_pass/blocs/myPassesDetails/my_passes_details_state.dart new file mode 100644 index 0000000..b3de1a1 --- /dev/null +++ b/lib/my_pass/blocs/myPassesDetails/my_passes_details_state.dart @@ -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 get props => []; +} + +class MyPassesDetailsInitial extends MyPassesDetailsState {} + +class MyPassesDetailsLoading extends MyPassesDetailsState {} + +class MyPassesDetailsLoaded extends MyPassesDetailsState { + final MyPassesDetailsModel data; + + const MyPassesDetailsLoaded({required this.data}); + + @override + List get props => [data]; +} + +class MyPassesDetailsError extends MyPassesDetailsState { + final String message; + + const MyPassesDetailsError({required this.message}); + + @override + List get props => [message]; +} diff --git a/lib/my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart b/lib/my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart new file mode 100644 index 0000000..0fec690 --- /dev/null +++ b/lib/my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart @@ -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 { + final MyPassesOffersRepository repository; + + List _allOffers = []; + + MyPassesOffersBloc(this.repository) : super(MyPassesOffersInitial()) { + on(_onLoadMyPassesOffers); + on(_onSearchMyPassesOffers); + } + + Future _onLoadMyPassesOffers( + LoadMyPassesOffers event, + Emitter 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 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, + ), + ); + } + } +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPassesOffers/my_passes_offers_event.dart b/lib/my_pass/blocs/myPassesOffers/my_passes_offers_event.dart new file mode 100644 index 0000000..d985d51 --- /dev/null +++ b/lib/my_pass/blocs/myPassesOffers/my_passes_offers_event.dart @@ -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); +} \ No newline at end of file diff --git a/lib/my_pass/blocs/myPassesOffers/my_passes_offers_state.dart b/lib/my_pass/blocs/myPassesOffers/my_passes_offers_state.dart new file mode 100644 index 0000000..554b5f9 --- /dev/null +++ b/lib/my_pass/blocs/myPassesOffers/my_passes_offers_state.dart @@ -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 offers; + final List categories; + + MyPassesOffersLoaded({ + required this.offers, + required this.categories, + }); +} + +class MyPassesOffersError extends MyPassesOffersState { + final String message; + MyPassesOffersError(this.message); +} \ No newline at end of file diff --git a/lib/my_pass/models/my_passes_details_model.dart b/lib/my_pass/models/my_passes_details_model.dart new file mode 100644 index 0000000..c2f45fa --- /dev/null +++ b/lib/my_pass/models/my_passes_details_model.dart @@ -0,0 +1,167 @@ +class MyPassesDetailsModel { + final City? city; + final List attractions; + final List offers; + + MyPassesDetailsModel({ + this.city, + required this.attractions, + required this.offers, + }); + + factory MyPassesDetailsModel.fromJson(Map? json) { + return MyPassesDetailsModel( + city: json?['city'] != null + ? City.fromJson(json?['city']) + : null, + attractions: (json?['attractions'] as List?) + ?.map((e) => Attraction.fromJson(e)) + .toList() ?? + [], + offers: (json?['offers'] as List?) + ?.map((e) => Offer.fromJson(e)) + .toList() ?? + [], + ); + } + + Map 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? 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 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? 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 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? json) { + return Offer( + id: json?['id'] ?? 0, + title: json?['title'] ?? '', + description: json?['description'] ?? '', + mobileBannerImage: json?['mobileBannerImage'] ?? '', + websiteBannerImage: json?['websiteBannerImage'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'mobileBannerImage': mobileBannerImage, + 'websiteBannerImage': websiteBannerImage, + }; + } +} diff --git a/lib/my_pass/models/my_passes_model.dart b/lib/my_pass/models/my_passes_model.dart new file mode 100644 index 0000000..9d8678f --- /dev/null +++ b/lib/my_pass/models/my_passes_model.dart @@ -0,0 +1,119 @@ +class MyPassesModel { + final List? data; + + MyPassesModel({ + this.data, + }); + + factory MyPassesModel.fromJson(List? json) { + return MyPassesModel( + data: json != null + ? json.map((e) => MyPassData.fromJson(e)).toList() + : [], + ); + } + + List 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? 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 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? json) { + return City( + id: json?['id'] ?? 0, + name: json?['name'] ?? '', + bannerImage: json?['bannerImage'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id ?? 0, + 'name': name ?? '', + 'bannerImage': bannerImage ?? '', + }; + } +} diff --git a/lib/my_pass/repository/my_passes_attractions_repository.dart b/lib/my_pass/repository/my_passes_attractions_repository.dart new file mode 100644 index 0000000..52ff59e --- /dev/null +++ b/lib/my_pass/repository/my_passes_attractions_repository.dart @@ -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 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'); + } + } +} \ No newline at end of file diff --git a/lib/my_pass/repository/my_passes_details_repository.dart b/lib/my_pass/repository/my_passes_details_repository.dart new file mode 100644 index 0000000..dfa1b0b --- /dev/null +++ b/lib/my_pass/repository/my_passes_details_repository.dart @@ -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 fetchPassDetails({ + required int passId, + }) async { + final response = await _apiService.getApi( + url: '${ApiUrls.passDetails}/$passId/details', + ); + + return MyPassesDetailsModel.fromJson(response.data); + } +} diff --git a/lib/my_pass/repository/my_passes_offers_repository.dart b/lib/my_pass/repository/my_passes_offers_repository.dart new file mode 100644 index 0000000..c1cda32 --- /dev/null +++ b/lib/my_pass/repository/my_passes_offers_repository.dart @@ -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 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); + } +} \ No newline at end of file diff --git a/lib/my_pass/repository/my_passes_repository.dart b/lib/my_pass/repository/my_passes_repository.dart new file mode 100644 index 0000000..8c3e27e --- /dev/null +++ b/lib/my_pass/repository/my_passes_repository.dart @@ -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 fetchMyPasses({ + String cardMode = "", + String sort = "", + }) async { + String url = ApiUrls.myPasses; + + List 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); + } +} diff --git a/lib/my_pass/views/my_pass_page_view.dart b/lib/my_pass/views/my_pass_page_view.dart index 60843bf..a7bcdcd 100644 --- a/lib/my_pass/views/my_pass_page_view.dart +++ b/lib/my_pass/views/my_pass_page_view.dart @@ -3,78 +3,337 @@ 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( - 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 createState() => _MyPassesViewState(); +} + +class _MyPassesViewState extends State { + String selectedCardMode = ""; + String selectedSort = ""; + + @override + void initState() { + super.initState(); + // Changed from FetchMyPasses to CheckLoginAndFetchPasses + context.read().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( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + "All", + style: GoogleFonts.poppins(fontSize: 14.sp), + ), + onTap: () { + setState(() { + selectedCardMode = ""; + }); + Navigator.pop(context); + context.read().add(FetchMyPasses( + cardMode: "", + sort: selectedSort, + )); + }, + ), + ListTile( + title: Text( + "flexi", + style: GoogleFonts.poppins(fontSize: 14.sp), + ), + onTap: () { + setState(() { + selectedCardMode = "flexi"; + }); + Navigator.pop(context); + context.read().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().add(FetchMyPasses( + cardMode: "unlimited", + sort: selectedSort, + )); + }, + ), + ], + ), + ); + }, ); } - Widget _noPassView(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 30.h), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - 'assets/images/no_pass.png', // your woman sitting image - height: 180.h, - ), - SizedBox(height: 20.h), - Text( - "You Don’t 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'); - }, - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric(vertical: 14.h), - decoration: BoxDecoration( - color: const Color(0xffFF5A5F), - borderRadius: BorderRadius.circular(30.r), + 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().add(FetchMyPasses( + cardMode: selectedCardMode, + sort: "", + )); + }, ), - child: Center( + ListTile( + title: Text( + "latest", + style: GoogleFonts.poppins(fontSize: 14.sp), + ), + onTap: () { + setState(() { + selectedSort = "latest"; + }); + Navigator.pop(context); + context.read().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().add(FetchMyPasses( + cardMode: selectedCardMode, + sort: "oldest", + )); + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: BlocBuilder( + 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, + ), + SizedBox(height: 10.h), + Row( + children: [ + GestureDetector( + onTap: _showSortBottomSheet, + child: Container( + width: 130.w, + height: 36.h, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: const Color(0xffFEE7E7), + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: const Color(0xffFDCDCE)), + ), + child: Row( + children: [ + Text( + selectedSort.isEmpty ? "Sort by Date" : selectedSort, + style: GoogleFonts.poppins(fontSize: 12.sp), + ), + const Spacer(), + const Icon(Icons.sort, size: 16), + ], + ), + ), + ), + SizedBox(width: 10.w), + GestureDetector( + onTap: _showCardModeBottomSheet, + child: Container( + height: 36.h, + width: 130.w, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: const Color(0xffFEE7E7), + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: const Color(0xffFDCDCE)), + ), + child: Row( + children: [ + Text( + selectedCardMode.isEmpty ? "All" : selectedCardMode, + style: GoogleFonts.poppins(fontSize: 12.sp), + ), + const Spacer(), + const Icon(Icons.keyboard_arrow_down_rounded, size: 18), + ], + ), + ), + ), + ], + ), + SizedBox(height: 20.h), + 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( - "Buy a Pass", - style: GoogleFonts.poppins( - color: Colors.white, - fontSize: 14.sp, - fontWeight: FontWeight.w600, + 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), + ], ), ), ], @@ -82,87 +341,84 @@ class MyPassesView extends StatelessWidget { ); } - Widget _passListView(List passes) { - return Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: SingleChildScrollView( - padding: EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,), - SizedBox(height: 10.h), - Row( - children: [ - Container( - width: 130.w, - height: 36.h, - padding: EdgeInsets.symmetric(horizontal: 12.w), - decoration: BoxDecoration( - color: const Color(0xffFEE7E7), - borderRadius: BorderRadius.circular(8.r), - border: Border.all(color: const Color(0xffFDCDCE)), - ), - child: Row( - children: [ - Text( - "Sort by Date", - style: GoogleFonts.poppins(fontSize: 12.sp), - ), - const Spacer(), - const Icon(Icons.sort, size: 16), - ], - ), - ), - SizedBox(width: 10.w), - Container( - height: 36.h, - width: 130.w, - padding: EdgeInsets.symmetric(horizontal: 12.w), - decoration: BoxDecoration( - color: const Color(0xffFEE7E7), - borderRadius: BorderRadius.circular(8.r), - border: Border.all(color: const Color(0xffFDCDCE)), - ), - child: Row( - children: [ - Text( - "All", - style: GoogleFonts.poppins(fontSize: 12.sp), - ), - const Spacer(), - const Icon(Icons.keyboard_arrow_down_rounded, size: 18), - ], - ), - ), - ], - ), - SizedBox(height: 20.h), - ListView.builder( - itemCount: passes.length, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - final pass = passes[index]; - return Padding( - padding: EdgeInsets.only(bottom: 16.h), - child: InkWell( - onTap: (){ - context.read().add(SelectPass(pass)); - Navigator.of( - context, - ).pushNamed(RouteConstants.qrPage); - }, - child: PassTicketCard(pass: pass), - ), - ); - }, - ), - ], + 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().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(), + itemBuilder: (context, index) { + final pass = passes[index]; + return Padding( + padding: EdgeInsets.only(bottom: 16.h), + child: InkWell( + onTap: () { + Navigator.of(context).pushNamed( + RouteConstants.qrPage, + arguments: pass.id, // Pass your booking ID here + ); + }, + child: PassTicketCard(pass: pass), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/my_pass/views/pass_attraction_details_view.dart b/lib/my_pass/views/pass_attraction_details_view.dart new file mode 100644 index 0000000..5b4a432 --- /dev/null +++ b/lib/my_pass/views/pass_attraction_details_view.dart @@ -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( + 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), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/my_pass/views/pass_attractions_page_view.dart b/lib/my_pass/views/pass_attractions_page_view.dart new file mode 100644 index 0000000..d350974 --- /dev/null +++ b/lib/my_pass/views/pass_attractions_page_view.dart @@ -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( + builder: (context, state) { + final bloc = context.read(); + + 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(), + ); + } +} \ No newline at end of file diff --git a/lib/my_pass/views/pass_details_page_view.dart b/lib/my_pass/views/pass_details_page_view.dart new file mode 100644 index 0000000..f577566 --- /dev/null +++ b/lib/my_pass/views/pass_details_page_view.dart @@ -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 createState() => _PassDetailsViewState(); +} + +class _PassDetailsViewState extends State { + @override + void initState() { + super.initState(); + context.read().add( + FetchMyPassDetails(passId: widget.bookingId), + ); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/my_pass/views/qr_pass_page_view.dart b/lib/my_pass/views/qr_pass_page_view.dart deleted file mode 100644 index 1ea3a98..0000000 --- a/lib/my_pass/views/qr_pass_page_view.dart +++ /dev/null @@ -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( - 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()); - } - }, - ); - } -} diff --git a/lib/my_pass/views/search_pass_offers_with_listing.dart b/lib/my_pass/views/search_pass_offers_with_listing.dart new file mode 100644 index 0000000..cacec05 --- /dev/null +++ b/lib/my_pass/views/search_pass_offers_with_listing.dart @@ -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 createState() => _PassOffersScreenState(); +} + +class _PassOffersScreenState extends State { + 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().add(SearchMyPassesOffers(value)); + }, + ), + ), + SizedBox(height: 20.h), + + /// Dynamic Categories + BlocBuilder( + 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() + .add(LoadMyPassesOffers(cityXid: widget.cityId)); + } else { + // Select new category + selectedCategoryId = category.id; + context.read().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( + 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), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/my_pass/widgets/pass_attraction_card.dart b/lib/my_pass/widgets/pass_attraction_card.dart new file mode 100644 index 0000000..60972bb --- /dev/null +++ b/lib/my_pass/widgets/pass_attraction_card.dart @@ -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 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, + ); + } +} \ No newline at end of file diff --git a/lib/my_pass/widgets/pass_widget.dart b/lib/my_pass/widgets/pass_widget.dart index ace5562..6f864a5 100644 --- a/lib/my_pass/widgets/pass_widget.dart +++ b/lib/my_pass/widgets/pass_widget.dart @@ -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 - ), + fontSize: 11.sp, + color: Colors.black, + 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 { final double cornerRadius; final double notchRadius; @@ -180,10 +176,11 @@ class _TicketClipper extends CustomClipper { )); 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 { 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 @@ -282,4 +272,4 @@ class _TicketBackgroundPainter extends CustomPainter { borderColor != oldDelegate.borderColor || shadowColor != oldDelegate.shadowColor; } -} +} \ No newline at end of file diff --git a/lib/networkApiServices/api_urls.dart b/lib/networkApiServices/api_urls.dart index 87488dc..bab2aec 100644 --- a/lib/networkApiServices/api_urls.dart +++ b/lib/networkApiServices/api_urls.dart @@ -10,16 +10,26 @@ 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"; + + static const myItineraries = "$baseUrl/mobile/itinerary/all-initineraries"; + static const getItineraryCities = + "$baseUrl/mobile/itinerary/cities-with-icons"; //Post Apis static const createAccount = "$baseUrl/mobile/user/register"; @@ -28,4 +38,4 @@ class ApiUrls { static const submitTicket = "$baseUrl/mobile/user/support"; static const createPostCard = "$baseUrl/mobile/postcards"; static const addToCartPasses = "$baseUrl/mobile/passes/add-to-cart"; -} \ No newline at end of file +} diff --git a/lib/networkApiServices/network_api_services.dart b/lib/networkApiServices/network_api_services.dart index 41d9baf..73d79da 100644 --- a/lib/networkApiServices/network_api_services.dart +++ b/lib/networkApiServices/network_api_services.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../localPreference/local_preference.dart'; @@ -34,14 +36,17 @@ class NetworkApiService { const maxRetries = 2; final currentRetry = options.extra['retry'] as int? ?? 0; - final shouldRetry = currentRetry < maxRetries && + final shouldRetry = + currentRetry < maxRetries && (err.type == DioExceptionType.connectionTimeout || err.type == DioExceptionType.sendTimeout || err.type == DioExceptionType.receiveTimeout); if (shouldRetry) { if (kDebugMode) { - print('🔁 Retrying request (${currentRetry + 1}) => ${options.uri}'); + print( + '🔁 Retrying request (${currentRetry + 1}) => ${options.uri}', + ); } options.extra['retry'] = currentRetry + 1; @@ -65,6 +70,7 @@ class NetworkApiService { QueuedInterceptorsWrapper( onRequest: (options, handler) async { final token = await LocalPreference.getAccessToken(); + if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } @@ -179,6 +185,27 @@ class NetworkApiService { } } + // ================= DELETE ================= + Future deleteApi({ + required String url, + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + try { + return await _dio.delete( + url, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } on DioException catch (e) { + throw _handleError(e); + } + } + // ================= REFRESH TOKEN ================= Future _refreshToken() async { try { @@ -188,9 +215,7 @@ class NetworkApiService { final response = await _dio.post( ApiUrls.refreshToken, data: {"refreshToken": refreshToken}, - options: Options( - headers: {'Authorization': null}, - ), + options: Options(headers: {'Authorization': null}), ); await LocalPreference.setAccessToken(response.data['accessToken']); @@ -221,7 +246,7 @@ class NetworkApiService { case DioExceptionType.badCertificate: return "Bad certificate."; case DioExceptionType.badResponse: - // đŸ”Ĩ FIXED: Safely handle different response data types + // đŸ”Ĩ FIXED: Safely handle different response data types try { final responseData = error.response?.data; diff --git a/lib/offer_pass_detail/offer_pass_detail_view.dart b/lib/offer_pass_detail/offer_pass_detail_view.dart index f4572ff..1638d1e 100644 --- a/lib/offer_pass_detail/offer_pass_detail_view.dart +++ b/lib/offer_pass_detail/offer_pass_detail_view.dart @@ -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,12 +106,16 @@ class _OffersDetailsContent extends StatelessWidget { ), ), SizedBox(width: 8.w), - Text( - offer.partnerName, - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: Colors.white, + 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, ), ), @@ -299,4 +306,4 @@ class _OffersDetailsContent extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_bloc.dart b/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_bloc.dart new file mode 100644 index 0000000..2ddfa2d --- /dev/null +++ b/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_bloc.dart @@ -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 { + final AddToCartPostCardRepository repository; + + AddToCartPostCardBloc(this.repository) + : super(AddToCartPostCardInitial()) { + on(_onAddToCartRequested); + } + + Future _onAddToCartRequested( + AddToCartPostCardRequested event, + Emitter 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())); + } + } +} diff --git a/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_event.dart b/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_event.dart new file mode 100644 index 0000000..5aece8e --- /dev/null +++ b/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_event.dart @@ -0,0 +1,64 @@ +import 'dart:io'; +import 'package:equatable/equatable.dart'; + +abstract class AddToCartPostCardEvent extends Equatable { + const AddToCartPostCardEvent(); + + @override + List 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 get props => [ + countryName, + cityName, + stateName, + zipCode, + address1, + address2, + pcTitle, + pcContent, + pcImageFile, + pcNumber, + pcDatetime, + fullname, + emailAddress, + mobileNumber, + isdCode, + ]; +} diff --git a/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_state.dart b/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_state.dart new file mode 100644 index 0000000..7af90c7 --- /dev/null +++ b/lib/postcard/blocs/addToCartPostcard/add_to_cart_postcard_state.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +abstract class AddToCartPostCardState extends Equatable { + const AddToCartPostCardState(); + + @override + List 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 get props => [ + postcardId, + pcNumber, + baseAmount, + totalTaxAmount, + totalAmount, + ]; +} + +class AddToCartPostCardFailure extends AddToCartPostCardState { + final String message; + + const AddToCartPostCardFailure(this.message); + + @override + List get props => [message]; +} diff --git a/lib/postcard/blocs/edit_postcard/edit_postcard_bloc.dart b/lib/postcard/blocs/edit_postcard/edit_postcard_bloc.dart new file mode 100644 index 0000000..5303700 --- /dev/null +++ b/lib/postcard/blocs/edit_postcard/edit_postcard_bloc.dart @@ -0,0 +1,26 @@ +import 'dart:developer'; + +import 'package:bloc/bloc.dart'; +import 'package:citycards_customer/postcard/models/my_postcard_model.dart'; +import 'package:citycards_customer/postcard/repository/my_postcard_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'edit_postcard_event.dart'; +part 'edit_postcard_state.dart'; + +class EditPostcardBloc extends Bloc { + EditPostcardBloc() : super(EditPostcardInitial()) { + on((event, emit) async { + try { + emit(EditPostcardLoading()); + await MyPostCardsRepository().editMyPostCards( + postcard: event.myPostCard, + ); + log("Edit PostCard Successfully"); + emit(EditPostcardSuccessfull()); + } catch (e) { + emit(EditPostcardError(error: "Failed to edit postcard")); + } + }); + } +} diff --git a/lib/postcard/blocs/edit_postcard/edit_postcard_event.dart b/lib/postcard/blocs/edit_postcard/edit_postcard_event.dart new file mode 100644 index 0000000..61b14be --- /dev/null +++ b/lib/postcard/blocs/edit_postcard/edit_postcard_event.dart @@ -0,0 +1,13 @@ +part of 'edit_postcard_bloc.dart'; + +class EditPostcardEvent extends Equatable { + const EditPostcardEvent(); + + @override + List get props => []; +} + +class EditPostCard extends EditPostcardEvent { + final MyPostCard myPostCard; + const EditPostCard({required this.myPostCard}); +} diff --git a/lib/postcard/blocs/edit_postcard/edit_postcard_state.dart b/lib/postcard/blocs/edit_postcard/edit_postcard_state.dart new file mode 100644 index 0000000..e0c9d74 --- /dev/null +++ b/lib/postcard/blocs/edit_postcard/edit_postcard_state.dart @@ -0,0 +1,19 @@ +part of 'edit_postcard_bloc.dart'; + +class EditPostcardState extends Equatable { + const EditPostcardState(); + + @override + List get props => []; +} + +class EditPostcardInitial extends EditPostcardState {} + +class EditPostcardLoading extends EditPostcardState {} + +class EditPostcardSuccessfull extends EditPostcardState {} + +class EditPostcardError extends EditPostcardState { + final String error; + const EditPostcardError({required this.error}); +} diff --git a/lib/postcard/blocs/myPostCards/my_postcard_bloc.dart b/lib/postcard/blocs/myPostCards/my_postcard_bloc.dart index fa9b4d5..286a3e1 100644 --- a/lib/postcard/blocs/myPostCards/my_postcard_bloc.dart +++ b/lib/postcard/blocs/myPostCards/my_postcard_bloc.dart @@ -1,6 +1,9 @@ +import 'dart:developer'; + import 'package:citycards_customer/localPreference/local_preference.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'dart:developer' as developer; +import '../../models/my_postcard_model.dart'; import '../../repository/my_postcard_repository.dart'; import 'my_postcard_event.dart'; import 'my_postcard_state.dart'; @@ -8,19 +11,21 @@ import 'my_postcard_state.dart'; class MyPostCardBloc extends Bloc { final MyPostCardsRepository repository; - MyPostCardBloc({required this.repository}) : super(const MyPostCardInitial()) { + MyPostCardBloc({required this.repository}) + : super(const MyPostCardInitial()) { on(_onCheckLoginStatus); on(_onFetchDraftPostCards); on(_onFetchOrderPostCards); on(_onRefreshDraftPostCards); on(_onRefreshOrderPostCards); + on(_onDeletePostCard); } /// Handle checking login status Future _onCheckLoginStatus( - CheckLoginStatus event, - Emitter emit, - ) async { + CheckLoginStatus event, + Emitter emit, + ) async { developer.log('🔍 Checking login status...', name: 'MyPostCardBloc'); emit(const MyPostCardCheckingLogin()); @@ -29,20 +34,28 @@ class MyPostCardBloc extends Bloc { developer.log('📊 Login status: $isLogin', name: 'MyPostCardBloc'); if (isLogin) { - developer.log('✅ User is logged in - initializing state', name: 'MyPostCardBloc'); + developer.log( + '✅ User is logged in - initializing state', + name: 'MyPostCardBloc', + ); // User is logged in, initialize with empty lists and loading states - emit(const MyPostCardLoaded( - draftPostCards: [], - orderPostCards: [], - isDraftLoading: true, - isOrderLoading: true, - )); + emit( + const MyPostCardLoaded( + draftPostCards: [], + orderPostCards: [], + isDraftLoading: true, + isOrderLoading: true, + ), + ); // Fetch both drafts and orders add(const FetchDraftPostCards()); add(const FetchOrderPostCards()); } else { - developer.log('❌ User is NOT logged in - emitting MyPostCardNotLoggedIn', name: 'MyPostCardBloc'); + developer.log( + '❌ User is NOT logged in - emitting MyPostCardNotLoggedIn', + name: 'MyPostCardBloc', + ); // User is not logged in emit(const MyPostCardNotLoggedIn()); } @@ -55,9 +68,9 @@ class MyPostCardBloc extends Bloc { /// Handle fetching draft postcards Future _onFetchDraftPostCards( - FetchDraftPostCards event, - Emitter emit, - ) async { + FetchDraftPostCards event, + Emitter emit, + ) async { developer.log('đŸ“Ĩ Fetching draft postcards...', name: 'MyPostCardBloc'); // Get current state final currentState = state; @@ -69,23 +82,33 @@ class MyPostCardBloc extends Bloc { try { final draftPostCards = await repository.fetchMyPostCards(type: 'draft'); - developer.log('✅ Draft postcards fetched: ${draftPostCards.length} items', name: 'MyPostCardBloc'); + developer.log( + '✅ Draft postcards fetched: ${draftPostCards.length} items', + name: 'MyPostCardBloc', + ); if (state is MyPostCardLoaded) { // Update with fetched drafts - emit((state as MyPostCardLoaded).copyWith( - draftPostCards: draftPostCards, - isDraftLoading: false, - )); + emit( + (state as MyPostCardLoaded).copyWith( + draftPostCards: draftPostCards, + isDraftLoading: false, + ), + ); } else { - developer.log('âš ī¸ State is not MyPostCardLoaded, creating new state', name: 'MyPostCardBloc'); + developer.log( + 'âš ī¸ State is not MyPostCardLoaded, creating new state', + name: 'MyPostCardBloc', + ); // Fallback: create new loaded state (shouldn't normally happen) - emit(MyPostCardLoaded( - draftPostCards: draftPostCards, - orderPostCards: const [], - isDraftLoading: false, - isOrderLoading: false, - )); + emit( + MyPostCardLoaded( + draftPostCards: draftPostCards, + orderPostCards: const [], + isDraftLoading: false, + isOrderLoading: false, + ), + ); } } catch (error) { developer.log('❌ Error fetching drafts: $error', name: 'MyPostCardBloc'); @@ -95,18 +118,37 @@ class MyPostCardBloc extends Bloc { } // Emit error state - emit(MyPostCardError( - errorMessage: error.toString(), - errorType: 'draft', - )); + emit(MyPostCardError(errorMessage: error.toString(), errorType: 'draft')); + } + } + + Future _onDeletePostCard( + DeleteDraftPostCards event, + Emitter emit, + ) async { + if (state is MyPostCardLoaded) { + MyPostCardLoaded currentState = state as MyPostCardLoaded; + try { + emit(currentState.copyWith(isDeleteLoading: true)); + await MyPostCardsRepository().deleteMyPostCards(event.id); + + List items = currentState.draftPostCards; + items.removeWhere((e) => e.id == event.id); + emit( + currentState.copyWith(draftPostCards: items, isDeleteLoading: false), + ); + } catch (e) { + log("Erro - $e"); + emit(currentState.copyWith(isDeleteLoading: false)); + } } } /// Handle fetching order postcards Future _onFetchOrderPostCards( - FetchOrderPostCards event, - Emitter emit, - ) async { + FetchOrderPostCards event, + Emitter emit, + ) async { developer.log('đŸ“Ĩ Fetching order postcards...', name: 'MyPostCardBloc'); // Get current state final currentState = state; @@ -118,23 +160,33 @@ class MyPostCardBloc extends Bloc { try { final orderPostCards = await repository.fetchMyPostCards(type: 'orders'); - developer.log('✅ Order postcards fetched: ${orderPostCards.length} items', name: 'MyPostCardBloc'); + developer.log( + '✅ Order postcards fetched: ${orderPostCards.length} items', + name: 'MyPostCardBloc', + ); if (state is MyPostCardLoaded) { // Update with fetched orders - emit((state as MyPostCardLoaded).copyWith( - orderPostCards: orderPostCards, - isOrderLoading: false, - )); + emit( + (state as MyPostCardLoaded).copyWith( + orderPostCards: orderPostCards, + isOrderLoading: false, + ), + ); } else { - developer.log('âš ī¸ State is not MyPostCardLoaded, creating new state', name: 'MyPostCardBloc'); + developer.log( + 'âš ī¸ State is not MyPostCardLoaded, creating new state', + name: 'MyPostCardBloc', + ); // Fallback: create new loaded state (shouldn't normally happen) - emit(MyPostCardLoaded( - draftPostCards: const [], - orderPostCards: orderPostCards, - isDraftLoading: false, - isOrderLoading: false, - )); + emit( + MyPostCardLoaded( + draftPostCards: const [], + orderPostCards: orderPostCards, + isDraftLoading: false, + isOrderLoading: false, + ), + ); } } catch (error) { developer.log('❌ Error fetching orders: $error', name: 'MyPostCardBloc'); @@ -144,58 +196,61 @@ class MyPostCardBloc extends Bloc { } // Emit error state - emit(MyPostCardError( - errorMessage: error.toString(), - errorType: 'order', - )); + emit(MyPostCardError(errorMessage: error.toString(), errorType: 'order')); } } /// Handle refreshing draft postcards Future _onRefreshDraftPostCards( - RefreshDraftPostCards event, - Emitter emit, - ) async { + RefreshDraftPostCards event, + Emitter emit, + ) async { developer.log('🔄 Refreshing draft postcards...', name: 'MyPostCardBloc'); try { final draftPostCards = await repository.fetchMyPostCards(type: 'draft'); - developer.log('✅ Draft postcards refreshed: ${draftPostCards.length} items', name: 'MyPostCardBloc'); + developer.log( + '✅ Draft postcards refreshed: ${draftPostCards.length} items', + name: 'MyPostCardBloc', + ); if (state is MyPostCardLoaded) { - emit((state as MyPostCardLoaded).copyWith( - draftPostCards: draftPostCards, - )); + emit( + (state as MyPostCardLoaded).copyWith(draftPostCards: draftPostCards), + ); } } catch (error) { - developer.log('❌ Error refreshing drafts: $error', name: 'MyPostCardBloc'); - emit(MyPostCardError( - errorMessage: error.toString(), - errorType: 'draft', - )); + developer.log( + '❌ Error refreshing drafts: $error', + name: 'MyPostCardBloc', + ); + emit(MyPostCardError(errorMessage: error.toString(), errorType: 'draft')); } } /// Handle refreshing order postcards Future _onRefreshOrderPostCards( - RefreshOrderPostCards event, - Emitter emit, - ) async { + RefreshOrderPostCards event, + Emitter emit, + ) async { developer.log('🔄 Refreshing order postcards...', name: 'MyPostCardBloc'); try { final orderPostCards = await repository.fetchMyPostCards(type: 'orders'); - developer.log('✅ Order postcards refreshed: ${orderPostCards.length} items', name: 'MyPostCardBloc'); + developer.log( + '✅ Order postcards refreshed: ${orderPostCards.length} items', + name: 'MyPostCardBloc', + ); if (state is MyPostCardLoaded) { - emit((state as MyPostCardLoaded).copyWith( - orderPostCards: orderPostCards, - )); + emit( + (state as MyPostCardLoaded).copyWith(orderPostCards: orderPostCards), + ); } } catch (error) { - developer.log('❌ Error refreshing orders: $error', name: 'MyPostCardBloc'); - emit(MyPostCardError( - errorMessage: error.toString(), - errorType: 'order', - )); + developer.log( + '❌ Error refreshing orders: $error', + name: 'MyPostCardBloc', + ); + emit(MyPostCardError(errorMessage: error.toString(), errorType: 'order')); } } -} \ No newline at end of file +} diff --git a/lib/postcard/blocs/myPostCards/my_postcard_event.dart b/lib/postcard/blocs/myPostCards/my_postcard_event.dart index 7c25209..4e931cc 100644 --- a/lib/postcard/blocs/myPostCards/my_postcard_event.dart +++ b/lib/postcard/blocs/myPostCards/my_postcard_event.dart @@ -17,6 +17,11 @@ class FetchDraftPostCards extends MyPostCardEvent { const FetchDraftPostCards(); } +class DeleteDraftPostCards extends MyPostCardEvent { + final int id; + const DeleteDraftPostCards({required this.id}); +} + /// Event to fetch order postcards class FetchOrderPostCards extends MyPostCardEvent { const FetchOrderPostCards(); @@ -30,4 +35,4 @@ class RefreshDraftPostCards extends MyPostCardEvent { /// Event to refresh order postcards class RefreshOrderPostCards extends MyPostCardEvent { const RefreshOrderPostCards(); -} \ No newline at end of file +} diff --git a/lib/postcard/blocs/myPostCards/my_postcard_state.dart b/lib/postcard/blocs/myPostCards/my_postcard_state.dart index cef9dd7..8b1e6b7 100644 --- a/lib/postcard/blocs/myPostCards/my_postcard_state.dart +++ b/lib/postcard/blocs/myPostCards/my_postcard_state.dart @@ -29,12 +29,14 @@ class MyPostCardLoaded extends MyPostCardState { final List orderPostCards; final bool isDraftLoading; final bool isOrderLoading; + final bool isDeleteLoading; const MyPostCardLoaded({ required this.draftPostCards, required this.orderPostCards, this.isDraftLoading = false, this.isOrderLoading = false, + this.isDeleteLoading = false, }); @override @@ -43,6 +45,7 @@ class MyPostCardLoaded extends MyPostCardState { orderPostCards, isDraftLoading, isOrderLoading, + isDeleteLoading, ]; /// Helper method to create a copy with updated values @@ -51,12 +54,14 @@ class MyPostCardLoaded extends MyPostCardState { List? orderPostCards, bool? isDraftLoading, bool? isOrderLoading, + bool? isDeleteLoading, }) { return MyPostCardLoaded( draftPostCards: draftPostCards ?? this.draftPostCards, orderPostCards: orderPostCards ?? this.orderPostCards, isDraftLoading: isDraftLoading ?? this.isDraftLoading, isOrderLoading: isOrderLoading ?? this.isOrderLoading, + isDeleteLoading: isDeleteLoading ?? this.isDeleteLoading, ); } } @@ -66,11 +71,8 @@ class MyPostCardError extends MyPostCardState { final String errorMessage; final String errorType; // 'draft' or 'order' - const MyPostCardError({ - required this.errorMessage, - required this.errorType, - }); + const MyPostCardError({required this.errorMessage, required this.errorType}); @override List get props => [errorMessage, errorType]; -} \ No newline at end of file +} diff --git a/lib/postcard/blocs/postcardCheckout/postcard_checkout_bloc.dart b/lib/postcard/blocs/postcardCheckout/postcard_checkout_bloc.dart index 016b511..2183512 100644 --- a/lib/postcard/blocs/postcardCheckout/postcard_checkout_bloc.dart +++ b/lib/postcard/blocs/postcardCheckout/postcard_checkout_bloc.dart @@ -1,3 +1,4 @@ +import 'dart:developer'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../repository/postcard_checkout_repository.dart'; import 'postcard_checkout_event.dart'; @@ -14,91 +15,100 @@ class PostcardCheckoutBloc on(_onUpdateCheckoutData); on(_onSaveAsDraft); on(_onSubmitPostcard); - on(_onConfirmPayment); // 🆕 NEW + on(_onConfirmPayment); } void _onUpdateAddress( - UpdateAddressEvent event, Emitter emit) { - emit(state.copyWith( - countryName: event.countryName, - cityName: event.cityName, - stateName: event.stateName, - zipCode: event.zipCode, - address1: event.address1, - address2: event.address2, - )); + UpdateAddressEvent event, + Emitter emit, + ) { + emit( + state.copyWith( + countryName: event.countryName, + cityName: event.cityName, + stateName: event.stateName, + zipCode: event.zipCode, + address1: event.address1, + address2: event.address2, + ), + ); } void _onUpdateContent( - UpdatePostcardContentEvent event, Emitter emit) { - emit(state.copyWith( - pcTitle: event.pcTitle, - pcContent: event.pcContent, - pcImageFile: event.pcImageFile, - )); + UpdatePostcardContentEvent event, + Emitter emit, + ) { + emit( + state.copyWith( + pcTitle: event.pcTitle, + pcContent: event.pcContent, + pcImageFile: event.pcImageFile, + ), + ); } void _onUpdateCheckoutData( - UpdateCheckoutDataEvent event, Emitter emit) { - emit(state.copyWith( - 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: event.isForSelf, - baseAmount: event.baseAmount, - totalTaxAmount: event.totalTaxAmount, - totalAmount: event.totalAmount, - )); + UpdateCheckoutDataEvent event, + Emitter emit, + ) { + emit( + state.copyWith( + 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: event.isForSelf, + baseAmount: event.baseAmount, + totalTaxAmount: event.totalTaxAmount, + totalAmount: event.totalAmount, + postcardId: event.postcardId, + ), + ); } Future _onSaveAsDraft( - SaveAsDraftEvent event, Emitter emit) async { + SaveAsDraftEvent event, + Emitter emit, + ) async { emit(state.copyWith(isLoading: true, error: null, isSuccess: false)); try { - // Validate that image file exists before submitting - if (state.pcImageFile == null) { + // Validate pcId exists + if (state.postcardId == null) { emit(state.copyWith( isLoading: false, - error: 'Please select a postcard image', + error: 'Postcard ID is missing', isSuccess: false, )); return; } + // Validate that image file exists before submitting + if (state.pcImageFile == null) { + emit( + state.copyWith( + isLoading: false, + error: 'Please select a postcard image', + isSuccess: false, + ), + ); + return; + } + 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 @@ -106,118 +116,128 @@ class PostcardCheckoutBloc response['order_id']?.toString() ?? response['id']?.toString(); - emit(state.copyWith( - isLoading: false, - isSuccess: true, - isDraft: true, - orderId: orderId, - )); + emit( + state.copyWith( + isLoading: false, + isSuccess: true, + isDraft: true, + orderId: orderId, + ), + ); } catch (e) { - emit(state.copyWith( - isLoading: false, - error: e.toString(), - isSuccess: false, - )); + emit( + state.copyWith( + isLoading: false, + error: e.toString(), + isSuccess: false, + ), + ); } } Future _onSubmitPostcard( - SubmitPostcardEvent event, Emitter emit) async { + SubmitPostcardEvent event, + Emitter emit, + ) async { emit(state.copyWith(isLoading: true, error: null, isSuccess: false)); try { - // Validate that image file exists before submitting - if (state.pcImageFile == null) { + // Validate pcId exists + if (state.postcardId == null) { emit(state.copyWith( isLoading: false, - error: 'Please select a postcard image', + error: 'Postcard ID is missing', isSuccess: false, )); return; } + // Validate that image file exists before submitting + if (state.pcImageFile == null) { + emit( + state.copyWith( + isLoading: false, + error: 'Please select a postcard image', + isSuccess: false, + ), + ); + return; + } + 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 + // Extract order ID from response final orderId = response['orderId']?.toString() ?? response['order_id']?.toString() ?? response['id']?.toString(); // Validate clientSecret is present if (clientSecret == null || clientSecret.isEmpty) { - emit(state.copyWith( - isLoading: false, - error: 'Payment initialization failed - no client secret received from server', - isSuccess: false, - )); + emit( + state.copyWith( + isLoading: false, + error: 'Payment initialization failed - no client secret received', + isSuccess: false, + ), + ); return; } - // 🆕 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 - orderId: orderId, - )); - } catch (e) { - emit(state.copyWith( - isLoading: false, - error: e.toString(), - isSuccess: false, - )); + // Emit success with clientSecret for payment processing + emit( + state.copyWith( + isLoading: false, + isSuccess: true, + isDraft: false, + postcardId: postcardId ?? state.postcardId, + clientSecret: clientSecret, + orderId: orderId, + ), + ); + } catch (e, stack) { + log("Payment Error: ${e.toString()}"); + log("Payment Stack: ${stack.toString()}"); + emit( + 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 _onConfirmPayment( - ConfirmPaymentEvent event, Emitter emit) async { - + ConfirmPaymentEvent event, + Emitter emit, + ) async { // Validate postcardId exists if (state.postcardId == null) { - emit(state.copyWith( - confirmationError: 'Cannot confirm payment - postcard ID is missing', - isConfirmingPayment: false, - isPaymentConfirmed: false, - )); + emit( + state.copyWith( + confirmationError: 'Cannot confirm payment - postcard ID is missing', + isConfirmingPayment: false, + isPaymentConfirmed: false, + ), + ); return; } - emit(state.copyWith( - isConfirmingPayment: true, - confirmationError: null, - isPaymentConfirmed: false, - )); + emit( + state.copyWith( + isConfirmingPayment: true, + confirmationError: null, + isPaymentConfirmed: false, + ), + ); try { final response = await repository.confirmPayment( @@ -227,17 +247,21 @@ class PostcardCheckoutBloc ); // Payment confirmation successful - emit(state.copyWith( - isConfirmingPayment: false, - isPaymentConfirmed: true, - confirmationError: null, - )); + emit( + state.copyWith( + isConfirmingPayment: false, + isPaymentConfirmed: true, + confirmationError: null, + ), + ); } catch (e) { - emit(state.copyWith( - isConfirmingPayment: false, - isPaymentConfirmed: false, - confirmationError: e.toString(), - )); + emit( + state.copyWith( + isConfirmingPayment: false, + isPaymentConfirmed: false, + confirmationError: e.toString(), + ), + ); } } } \ No newline at end of file diff --git a/lib/postcard/blocs/postcardCheckout/postcard_checkout_event.dart b/lib/postcard/blocs/postcardCheckout/postcard_checkout_event.dart index 765e6a7..db234da 100644 --- a/lib/postcard/blocs/postcardCheckout/postcard_checkout_event.dart +++ b/lib/postcard/blocs/postcardCheckout/postcard_checkout_event.dart @@ -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 }); } diff --git a/lib/postcard/blocs/postcard_creation_bloc.dart b/lib/postcard/blocs/postcard_creation_bloc.dart index 34eb3e6..4ca042b 100644 --- a/lib/postcard/blocs/postcard_creation_bloc.dart +++ b/lib/postcard/blocs/postcard_creation_bloc.dart @@ -17,7 +17,7 @@ class PostcardCreationBloc PostcardCreationBloc() : super( - const PostcardCreationState(currentStep: PostcardStep.uploadPhoto), + const PostcardCreationState(currentStep: PostcardStep.uploadPhoto, address: ''), ) { /* Navigation steps */ @@ -139,6 +139,7 @@ class PostcardCreationBloc fullName: event.fullName, emailId: event.emailId, phoneNumber: event.phoneNumber, + address: event.address, city: event.city, country: event.country, state: event.state, @@ -246,5 +247,32 @@ class PostcardCreationBloc on((event, emit) { emit(state.copyWith(isGift: event.isGift)); }); + + on((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 + String getFormattedMessage() { + if (state.message == null || state.message!.isEmpty) { + return ''; + } + + if (state.selectedFont == null || state.selectedFont!.isEmpty) { + // Default font (Poppins) + return '${state.message}'; + } + + return '${state.message}'; } } \ No newline at end of file diff --git a/lib/postcard/blocs/postcard_creation_events.dart b/lib/postcard/blocs/postcard_creation_events.dart index 8fd7f20..7168ede 100644 --- a/lib/postcard/blocs/postcard_creation_events.dart +++ b/lib/postcard/blocs/postcard_creation_events.dart @@ -41,6 +41,7 @@ class UpdatePurchaseFormData extends PostcardCreationEvent { final String? pcTitle; final String? fullName; final String? emailId; + final String address; final String? phoneNumber; final String? city; final String? country; @@ -56,6 +57,7 @@ class UpdatePurchaseFormData extends PostcardCreationEvent { this.country, this.state, this.zipCode, + required this.address, }); } @@ -66,4 +68,27 @@ class UpdatePostcardNumber extends PostcardCreationEvent { final String pcNumber; 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, + }); } \ No newline at end of file diff --git a/lib/postcard/blocs/postcard_creation_state.dart b/lib/postcard/blocs/postcard_creation_state.dart index d9c3064..06ba104 100644 --- a/lib/postcard/blocs/postcard_creation_state.dart +++ b/lib/postcard/blocs/postcard_creation_state.dart @@ -15,11 +15,22 @@ class PostcardCreationState { final String? fullName; final String? emailId; final String? phoneNumber; + final String address; final String? city; 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, @@ -39,7 +50,17 @@ class PostcardCreationState { this.country, this.state, this.zipCode, - this.pcNumber, // 🆕 ADD THIS + this.pcNumber, + required this.address, + // User profile data + this.userProfileFullName, + this.userProfileEmail, + this.userProfilePhone, + this.userProfileAddress, + this.userProfileCity, + this.userProfileState, + this.userProfileZipCode, + this.userProfileCountry, }); PostcardCreationState copyWith({ @@ -56,11 +77,21 @@ class PostcardCreationState { String? fullName, String? emailId, String? phoneNumber, + String? address, String? city, 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, @@ -76,11 +107,21 @@ class PostcardCreationState { fullName: fullName ?? this.fullName, emailId: emailId ?? this.emailId, phoneNumber: phoneNumber ?? this.phoneNumber, + address: address ?? this.address, city: city ?? this.city, 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, ); } } \ No newline at end of file diff --git a/lib/postcard/models/my_postcard_model.dart b/lib/postcard/models/my_postcard_model.dart index 1ff827e..936d72d 100644 --- a/lib/postcard/models/my_postcard_model.dart +++ b/lib/postcard/models/my_postcard_model.dart @@ -170,4 +170,81 @@ class MyPostCard { 'updatedAt': updatedAt.toIso8601String(), }; } + + MyPostCard copyWith({ + int? id, + int? userXid, + String? pcTitle, + String? pcNumber, + String? cityName, + DateTime? pcDatetime, + String? pcContent, + String? pcImagePath, + bool? isForSelf, + String? fullname, + String? emailAddress, + String? isdCode, + String? mobileNumber, + String? address1, + String? address2, + String? zipCode, + String? stateName, + String? countryName, + String? orderStatus, + double? baseAmount, + int? couponXid, + double? couponDiscountPercent, + double? couponDiscountAmount, + double? totalTaxAmount, + double? totalAmount, + bool? isPaid, + String? paymentMode, + String? paymentId, + String? paymentStatus, + String? paymentIntentId, + bool? isDraft, + DateTime? deliveredOn, + bool? isActive, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MyPostCard( + id: id ?? this.id, + userXid: userXid ?? this.userXid, + pcTitle: pcTitle ?? this.pcTitle, + pcNumber: pcNumber ?? this.pcNumber, + cityName: cityName ?? this.cityName, + pcDatetime: pcDatetime ?? this.pcDatetime, + pcContent: pcContent ?? this.pcContent, + pcImagePath: pcImagePath ?? this.pcImagePath, + isForSelf: isForSelf ?? this.isForSelf, + fullname: fullname ?? this.fullname, + emailAddress: emailAddress ?? this.emailAddress, + isdCode: isdCode ?? this.isdCode, + mobileNumber: mobileNumber ?? this.mobileNumber, + address1: address1 ?? this.address1, + address2: address2 ?? this.address2, + zipCode: zipCode ?? this.zipCode, + stateName: stateName ?? this.stateName, + countryName: countryName ?? this.countryName, + orderStatus: orderStatus ?? this.orderStatus, + baseAmount: baseAmount ?? this.baseAmount, + couponXid: couponXid ?? this.couponXid, + couponDiscountPercent: + couponDiscountPercent ?? this.couponDiscountPercent, + couponDiscountAmount: couponDiscountAmount ?? this.couponDiscountAmount, + totalTaxAmount: totalTaxAmount ?? this.totalTaxAmount, + totalAmount: totalAmount ?? this.totalAmount, + isPaid: isPaid ?? this.isPaid, + paymentMode: paymentMode ?? this.paymentMode, + paymentId: paymentId ?? this.paymentId, + paymentStatus: paymentStatus ?? this.paymentStatus, + paymentIntentId: paymentIntentId ?? this.paymentIntentId, + isDraft: isDraft ?? this.isDraft, + deliveredOn: deliveredOn ?? this.deliveredOn, + isActive: isActive ?? this.isActive, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } } diff --git a/lib/postcard/repository/my_postcard_repository.dart b/lib/postcard/repository/my_postcard_repository.dart index 2a5932b..61e63f7 100644 --- a/lib/postcard/repository/my_postcard_repository.dart +++ b/lib/postcard/repository/my_postcard_repository.dart @@ -1,3 +1,7 @@ +import 'dart:developer'; + +import 'package:dio/dio.dart'; + import '../../networkApiServices/network_api_services.dart'; import '../../networkApiServices/api_urls.dart'; import '../models/my_postcard_model.dart'; @@ -13,8 +17,61 @@ class MyPostCardsRepository { url: '${ApiUrls.myPostCards}?type=$type', ); - return (response.data as List) - .map((e) => MyPostCard.fromJson(e)) - .toList(); + return (response.data as List).map((e) => MyPostCard.fromJson(e)).toList(); + } + + Future editMyPostCards({required MyPostCard postcard}) async { + try { + final formData = FormData(); + + formData.fields.addAll([ + MapEntry('countryName', postcard.countryName), + MapEntry('cityName', postcard.cityName), + MapEntry('stateName', postcard.stateName), + MapEntry('zipCode', postcard.zipCode), + MapEntry('pcTitle', postcard.pcTitle), + MapEntry('pcContent', postcard.pcContent), + MapEntry('pcNumber', postcard.pcNumber), + MapEntry('pcDatetime', postcard.pcDatetime.toString()), + MapEntry('fullname', postcard.fullname), + MapEntry('isdCode', postcard.isdCode), + ]); + + if (postcard.address1.isNotEmpty) { + formData.fields.add(MapEntry('address1', postcard.address1)); + } + + if (postcard.address2.isNotEmpty) { + formData.fields.add(MapEntry('address2', postcard.address2)); + } + // final fileName = postcard.pcImagePath.split('/').last; + // formData.files.add( + // MapEntry( + // 'pcImage', + // await MultipartFile.fromFile( + // postcard.pcImagePath, + // filename: fileName, + // ), + // ), + // ); + await _apiService.putApi( + url: '${ApiUrls.editPostcard}/${postcard.id}', + data: formData, + ); + return; + } catch (e, stack) { + log("Edit PostCard Error - $e"); + log("Edit PostCard Error - $stack"); + } + } + + Future deleteMyPostCards(int id) async { + try { + await _apiService.deleteApi(url: '${ApiUrls.editPostcard}/$id'); + return; + } catch (e, stack) { + log("Delete PostCard Error - $e"); + log("Delete PostCard Error - $stack"); + } } } diff --git a/lib/postcard/repository/postcard_add_to_cart_repository.dart b/lib/postcard/repository/postcard_add_to_cart_repository.dart new file mode 100644 index 0000000..7ff00ad --- /dev/null +++ b/lib/postcard/repository/postcard_add_to_cart_repository.dart @@ -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> 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; + } 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> 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; + } catch (e, stackTrace) { + log( + '❌ confirmPayment FAILED', + error: e, + stackTrace: stackTrace, + ); + throw Exception('Failed to confirm payment: $e'); + } + } +} \ No newline at end of file diff --git a/lib/postcard/repository/postcard_checkout_repository.dart b/lib/postcard/repository/postcard_checkout_repository.dart index 3d82050..a40e3b3 100644 --- a/lib/postcard/repository/postcard_checkout_repository.dart +++ b/lib/postcard/repository/postcard_checkout_repository.dart @@ -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> 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; } 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> 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; } catch (e, stackTrace) { @@ -202,4 +102,4 @@ class CreatePostCardRepository { throw Exception('Failed to confirm payment: $e'); } } -} \ No newline at end of file +} diff --git a/lib/postcard/views/add_filter_step_page_view.dart b/lib/postcard/views/add_filter_step_page_view.dart index 92386f6..15168e8 100644 --- a/lib/postcard/views/add_filter_step_page_view.dart +++ b/lib/postcard/views/add_filter_step_page_view.dart @@ -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().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( diff --git a/lib/postcard/views/edit_postcard_view.dart b/lib/postcard/views/edit_postcard_view.dart new file mode 100644 index 0000000..2a04026 --- /dev/null +++ b/lib/postcard/views/edit_postcard_view.dart @@ -0,0 +1,377 @@ +import 'package:citycards_customer/postcard/blocs/edit_postcard/edit_postcard_bloc.dart'; +import 'package:citycards_customer/postcard/models/my_postcard_model.dart'; +import 'package:citycards_customer/postcard/widgets/dotted_border_container.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.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/custom_text.dart'; +import '../../networkApiServices/api_urls.dart'; +import '../widgets/edit_post_card/edit_message.dart'; +import '../widgets/edit_post_card/your_details.dart'; + +class EditPostcardView extends StatefulWidget { + final MyPostCard myPostCard; + const EditPostcardView({super.key, required this.myPostCard}); + + @override + State createState() => _EditPostcardViewState(); +} + +class _EditPostcardViewState extends State { + MyPostCard? postCard; + final EditPostcardBloc editPostcardBloc = EditPostcardBloc(); + + final _formKey = GlobalKey(); + + final _fullNameController = TextEditingController(); + final _addressController = TextEditingController(); + final _cityController = TextEditingController(); + final _zipCodeController = TextEditingController(); + + String? _selectedCountry; + String? _selectedState; + + @override + void dispose() { + _fullNameController.dispose(); + _addressController.dispose(); + _cityController.dispose(); + _zipCodeController.dispose(); + super.dispose(); + } + + @override + void initState() { + setState(() { + postCard = widget.myPostCard; + _fullNameController.text = widget.myPostCard.fullname; + _addressController.text = widget.myPostCard.address1; + _cityController.text = widget.myPostCard.cityName; + _zipCodeController.text = widget.myPostCard.zipCode; + _selectedCountry = widget.myPostCard.countryName; + _selectedState = widget.myPostCard.stateName; + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + return Scaffold( + resizeToAvoidBottomInset: true, + backgroundColor: Colors.white, + body: BlocConsumer( + bloc: editPostcardBloc, + listener: (ctxx, state) async { + if (state is EditPostcardSuccessfull) { + if (Navigator.canPop(ctxx)) { + Navigator.pop(ctxx, true); + } + } else if (state is EditPostcardError) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(state.error))); + } + }, + builder: (context, state) { + return Stack( + children: [ + SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommonAppBar( + isWhiteLogo: false, + isProfilePage: false, + showCart: true, + showDivider: true, + ), + Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Icon(Icons.arrow_back), + ), + SizedBox(width: 8.w), + CustomText(text: "Edit Postcard", size: 12.sp), + ], + ), + SizedBox(height: 10.h), + Text( + "Upload Image", + style: GoogleFonts.poppins( + color: Color(0XFF212121), + fontSize: 18.sp, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 2.h), + Text( + "Edit your own unique postcards by uploading images that capture your unforgettable moments.", + style: GoogleFonts.poppins( + color: Color(0XFF000000).withValues(alpha: 0.6), + fontSize: 14.sp, + fontWeight: FontWeight.w400, + ), + ), + SizedBox(height: 10.h), + Row( + // crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: CustomPaint( + painter: DottedBorderPainter(), + child: Container( + padding: EdgeInsets.all(10), + height: size.width * 0.45, + width: size.width, + constraints: BoxConstraints(minHeight: 150), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + '${ApiUrls.baseUrl}${postCard!.pcImagePath}', + height: size.width * 0.45, + width: size.width, + fit: BoxFit.cover, + loadingBuilder: + (context, child, loadingProgress) { + if (loadingProgress == null) + return child; + return Container( + height: size.width * 0.45, + width: size.width, + color: Colors.grey[300], + child: const Center( + child: + CircularProgressIndicator( + color: Color(0xffF95F62), + strokeWidth: 2, + ), + ), + ); + }, + errorBuilder: + (context, error, stackTrace) { + return Container( + height: size.width * 0.45, + width: size.width, + color: Colors.grey[300], + child: const Icon( + Icons.image_not_supported, + color: Colors.grey, + ), + ); + }, + ), + ), + ), + ), + ), + Expanded( + child: Container( + height: size.width * 0.5, + width: size.width, + constraints: BoxConstraints(minHeight: 150), + padding: EdgeInsets.all(10), + child: Column( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + imageButton( + title: 'Take a photo', + icon: Icons.camera_alt_outlined, + width: size.width, + ), + imageButton( + title: 'Upload Again', + icon: Icons.refresh, + width: size.width, + ), + imageButton( + title: 'Edit Filters', + width: size.width, + ), + ], + ), + ), + ), + ], + ), + SizedBox(height: 10.h), + Text( + "Edit message", + style: GoogleFonts.poppins( + color: Color(0XFF212121), + fontSize: 18.sp, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 2.h), + Text( + "Edit your own unique postcards to cherish your unforgettable moments.", + style: GoogleFonts.poppins( + color: Color(0XFF000000).withValues(alpha: 0.6), + fontSize: 14.sp, + fontWeight: FontWeight.w400, + ), + ), + SizedBox(height: 10.h), + EditMessage( + text: postCard!.pcContent, + onChange: (message, font) { + postCard = postCard!.copyWith( + pcContent: getFormattedMessage(message, font), + ); + }, + ), + SizedBox(height: 10.h), + Form( + key: _formKey, + child: EditYourdetails( + fullNameController: _fullNameController, + addressController: _addressController, + cityController: _cityController, + zipCodeController: _zipCodeController, + selectedCountry: _selectedCountry ?? "", + selectedState: _selectedState ?? "", + formKey: _formKey, + selectState: (String p1) { + setState(() { + _selectedState = p1; + }); + }, + selectCountry: (String p1) { + setState(() { + _selectedCountry = p1; + }); + }, + ), + ), + + const SizedBox(height: 30), + + // Next Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + postCard = postCard!.copyWith( + fullname: _fullNameController.text, + address1: _addressController.text, + cityName: _cityController.text, + zipCode: _zipCodeController.text, + stateName: _selectedState, + countryName: _selectedCountry, + ); + editPostcardBloc.add( + EditPostCard(myPostCard: postCard!), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xffF95F62), + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(40), + ), + ), + child: Text( + "Next", + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: state is EditPostcardSuccessfull + ? Center( + child: SizedBox( + width: 25, + height: 25, + child: CircularProgressIndicator( + color: Color(0XFFF95F62), + ), + ), + ) + : SizedBox(), + ), + ], + ); + }, + ), + ); + } + + Widget imageButton({ + Function()? onPressed, + required String title, + IconData? icon, + required double width, + }) { + return SizedBox( + width: width, + child: OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + side: const BorderSide(color: Color(0xffF95F62)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + style: TextStyle( + color: Color(0xffF95F62), + fontWeight: FontWeight.w500, + fontSize: 12.sp, + ), + ), + SizedBox(width: icon != null ? 8 : 0), + icon != null ? Icon(icon, color: Color(0xffF95F62)) : SizedBox(), + ], + ), + ), + ); + } + + String getFormattedMessage(String message, String selectedFont) { + if (message.isEmpty) { + return ''; + } + + if (selectedFont.isEmpty) { + // Default font (Poppins) + return '$message'; + } + + return '$message'; + } +} diff --git a/lib/postcard/views/my_postcard_drafts_view.dart b/lib/postcard/views/my_postcard_drafts_view.dart index a9a5502..6553949 100644 --- a/lib/postcard/views/my_postcard_drafts_view.dart +++ b/lib/postcard/views/my_postcard_drafts_view.dart @@ -1,6 +1,11 @@ +import 'dart:developer'; + +import 'package:citycards_customer/postcard/blocs/edit_postcard/edit_postcard_bloc.dart'; +import 'package:citycards_customer/postcard/views/edit_postcard_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import '../../core/route_constants.dart'; @@ -22,9 +27,7 @@ class MyPostCardDraftView extends StatelessWidget { // Show loading indicator if drafts are loading if (state.isDraftLoading && state.draftPostCards.isEmpty) { return const Center( - child: CircularProgressIndicator( - color: Color(0xffF95F62), - ), + child: CircularProgressIndicator(color: Color(0xffF95F62)), ); } @@ -79,19 +82,43 @@ class MyPostCardDraftView extends StatelessWidget { } // Show the list of drafts - return RefreshIndicator( - onRefresh: () async { - context.read().add(const RefreshDraftPostCards()); - }, - color: const Color(0xffF95F62), - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: state.draftPostCards.length, - itemBuilder: (context, index) { - final postcard = state.draftPostCards[index]; - return _buildDraftCard(context, postcard); - }, - ), + return Stack( + children: [ + RefreshIndicator( + onRefresh: () async { + context.read().add( + const RefreshDraftPostCards(), + ); + }, + color: const Color(0xffF95F62), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: state.draftPostCards.length, + itemBuilder: (context, index) { + final postcard = state.draftPostCards[index]; + return _buildDraftCard(context, postcard); + }, + ), + ), + + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: state.isDeleteLoading == true + ? Center( + child: SizedBox( + width: 25, + height: 25, + child: CircularProgressIndicator( + color: Color(0XFFF95F62), + ), + ), + ) + : SizedBox(), + ), + ], ); } @@ -130,11 +157,16 @@ class MyPostCardDraftView extends StatelessWidget { const SizedBox(height: 24), ElevatedButton( onPressed: () { - context.read().add(const FetchDraftPostCards()); + context.read().add( + const FetchDraftPostCards(), + ); }, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xffF95F62), - padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 12.h), + padding: EdgeInsets.symmetric( + horizontal: 32.w, + vertical: 12.h, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(40), ), @@ -160,131 +192,213 @@ class MyPostCardDraftView extends StatelessWidget { Widget _buildDraftCard(BuildContext context, MyPostCard postcard) { return Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xffF95F62).withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xffF1F5F7)), + color: const Color(0xffF95F62), + borderRadius: BorderRadius.circular(10), + border: Border(left: BorderSide(width: 6, color: Color(0XFFF93232))), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - /// LEFT IMAGE - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.network( - '${ApiUrls.baseUrl}${postcard.pcImagePath}', - height: 72, - width: 72, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - height: 72, - width: 72, - color: Colors.grey[300], - child: const Center( - child: CircularProgressIndicator( - color: Color(0xffF95F62), - strokeWidth: 2, + margin: EdgeInsets.only(bottom: 15), + child: Slidable( + key: UniqueKey(), + startActionPane: ActionPane( + motion: const ScrollMotion(), + + // dismissible: DismissiblePane(onDismissed: () {}), + children: [ + SlidableAction( + onPressed: (ctx) { + context.read().add( + DeleteDraftPostCards(id: postcard.id), + ); + }, + flex: 3, + backgroundColor: Color(0XFFF93232), + foregroundColor: Colors.white, + icon: Icons.delete, + label: 'Delete', + autoClose: true, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + ), + ], + ), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: const Color(0XFFFFF5F5)), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// NUMBER + Text( + "#${postcard.pcNumber}", + style: GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + /// LEFT IMAGE + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + '${ApiUrls.baseUrl}${postcard.pcImagePath}', + height: 72, + width: 72, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + height: 72, + width: 72, + color: Colors.grey[300], + child: const Center( + child: CircularProgressIndicator( + color: Color(0xffF95F62), + strokeWidth: 2, + ), + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 72, + width: 72, + color: Colors.grey[300], + child: const Icon( + Icons.image_not_supported, + color: Colors.grey, + ), + ); + }, ), ), - ); - }, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 72, - width: 72, - color: Colors.grey[300], - child: const Icon( - Icons.image_not_supported, - color: Colors.grey, + + const SizedBox(width: 14), + + /// RIGHT CONTENT + Expanded( + child: Text( + postcard.pcTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + + style: GoogleFonts.poppins( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Colors.black87, + ), + ), ), - ); - }, - ), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider( + create: (context) => EditPostcardBloc(), + child: EditPostcardView(myPostCard: postcard), + ), + ), + ); + + if (result == true) { + // ignore: use_build_context_synchronously + context.read().add( + const RefreshDraftPostCards(), + ); + } + }, + + style: ElevatedButton.styleFrom( + backgroundColor: Color( + 0xfff95f62, + ).withValues(alpha: 0.1), + elevation: 0, + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Color(0XFFFDCDCE)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.edit_outlined, + size: 22, + color: Color(0XFFF95F62), + ), + SizedBox(width: 5), + Text( + "Edit", + style: GoogleFonts.poppins( + color: Color(0XFFF95F62), + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + SizedBox(width: 4), + Expanded( + child: ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: Color( + 0xfff95f62, + ).withValues(alpha: 0.1), + elevation: 0, + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Color(0XFFFDCDCE)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Transform.rotate( + angle: -45, + child: Icon( + Icons.send_outlined, + size: 22, + color: Color(0XFFF95F62), + ), + ), + SizedBox(width: 5), + Text( + "Send", + style: GoogleFonts.poppins( + color: Color(0XFFF95F62), + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ], ), - - const SizedBox(width: 14), - - /// RIGHT CONTENT - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - /// NUMBER - Text( - "#${postcard.pcNumber}", - style: GoogleFonts.poppins( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - - const SizedBox(height: 4), - - /// TITLE - Text( - postcard.pcTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.poppins( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Colors.black87, - ), - ), - - const SizedBox(height: 10), - - /// ICONS – BOTTOM RIGHT (UNDER TITLE) - Align( - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: () { - // delete - }, - child: Image.asset( - 'assets/icons/delete_icon.png', - width: 20, - height: 20, - ), - ), - const SizedBox(width: 16), - GestureDetector( - onTap: () { - // edit - }, - child: Image.asset( - 'assets/icons/edit_icon.png', - width: 20, - height: 20, - ), - ), - const SizedBox(width: 16), - GestureDetector( - onTap: () { - // send - }, - child: Image.asset( - 'assets/icons/send_icon.png', - width: 20, - height: 20, - ), - ), - ], - ), - ), - ], - ), - ), - ], + ), ), ); } -} \ No newline at end of file +} diff --git a/lib/postcard/views/my_postcard_orders_view.dart b/lib/postcard/views/my_postcard_orders_view.dart index 74811ad..ca0fc78 100644 --- a/lib/postcard/views/my_postcard_orders_view.dart +++ b/lib/postcard/views/my_postcard_orders_view.dart @@ -23,9 +23,7 @@ class MyPostCardOrdersView extends StatelessWidget { // Show loading indicator if orders are loading if (state.isOrderLoading && state.orderPostCards.isEmpty) { return const Center( - child: CircularProgressIndicator( - color: Color(0xffF95F62), - ), + child: CircularProgressIndicator(color: Color(0xffF95F62)), ); } @@ -131,11 +129,16 @@ class MyPostCardOrdersView extends StatelessWidget { const SizedBox(height: 24), ElevatedButton( onPressed: () { - context.read().add(const FetchOrderPostCards()); + context.read().add( + const FetchOrderPostCards(), + ); }, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xffF95F62), - padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 12.h), + padding: EdgeInsets.symmetric( + horizontal: 32.w, + vertical: 12.h, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(40), ), @@ -160,41 +163,71 @@ class MyPostCardOrdersView extends StatelessWidget { } Widget _buildOrderCard(BuildContext context, MyPostCard postcard) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Postcard Number above the card - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - "#${postcard.pcNumber}", - style: GoogleFonts.poppins( - color: Colors.black, - fontWeight: FontWeight.w500, - fontSize: 15.sp, - ), + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xffF95F62).withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xffF1F5F7)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + "#${postcard.pcNumber}", + style: GoogleFonts.poppins( + color: Colors.black.withValues(alpha: 0.4), + fontWeight: FontWeight.w400, + fontSize: 12.sp, + ), + ), + SizedBox(width: 10), + Spacer(), + Text( + "Status:", + style: GoogleFonts.poppins( + color: Colors.black, + fontWeight: FontWeight.w400, + fontSize: 10.sp, + ), + ), + SizedBox(width: 5), + Container( + padding: const EdgeInsets.fromLTRB(13, 7, 13, 7), + decoration: BoxDecoration( + color: _getStatusColor( + postcard.orderStatus, + ).withOpacity(0.16), + border: Border.all( + color: _getStatusBorderColor(postcard.orderStatus), + ), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + _getStatusText(postcard.orderStatus), + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w400, + fontSize: 8.54.sp, + ), + ), + ), + ], ), - ), - - // Order Card - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xffF95F62).withValues(alpha:0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xffF1F5F7), - ), - ), - child: Row( + SizedBox(width: 10), + Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Postcard Image ClipRRect( borderRadius: BorderRadius.circular(8), child: Image( - image: NetworkImage('${ApiUrls.baseUrl}${postcard.pcImagePath}'), + image: NetworkImage( + '${ApiUrls.baseUrl}${postcard.pcImagePath}', + ), height: 70.h, width: 70.w, fit: BoxFit.cover, @@ -233,98 +266,67 @@ class MyPostCardOrdersView extends StatelessWidget { // Postcard Details Expanded( - child: SizedBox( + child: Container( + alignment: Alignment.centerLeft, height: 60.h, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - postcard.pcTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.poppins( - color: Colors.black, - fontWeight: FontWeight.w400, - fontSize: 16.sp, - ), - ), - const SizedBox(height: 6), - Text( - "5 Post cards", - style: GoogleFonts.poppins( - color: Colors.black, - fontWeight: FontWeight.w400, - fontSize: 14.sp, - ), - ), - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - padding: const EdgeInsets.fromLTRB(13, 7, 13, 7), - decoration: BoxDecoration( - color: _getStatusColor(postcard.orderStatus).withOpacity(0.16), - border: Border.all( - color: _getStatusBorderColor(postcard.orderStatus), - ), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - _getStatusText(postcard.orderStatus), - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.w400, - fontSize: 8.54.sp, - ), - ), - ), - InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MyPostcardPreviewView( - postcard: postcard, - ), - ), - ); - }, - child: Row( - children: [ - Icon( - Icons.remove_red_eye_outlined, - size: 15, - color: const Color(0xffF95F62), - ), - SizedBox(width: 5.w), - Text( - "Preview", - style: TextStyle( - fontWeight: FontWeight.w400, - color: const Color(0xffF95F62), - fontSize: 13.sp, - ), - ), - ], - ), - ), - ], - ), - ], + child: Text( + postcard.pcTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.poppins( + color: Colors.black, + fontWeight: FontWeight.w400, + fontSize: 16.sp, + ), ), ), ), ], ), - ), - ], + Container( + margin: EdgeInsets.only(top: 10), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xfff95f62).withValues(alpha: 0.1), + elevation: 0, + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: Color(0XFFFDCDCE)), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + MyPostcardPreviewView(postcard: postcard), + ), + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.remove_red_eye_outlined, + size: 15, + color: const Color(0xffF95F62), + ), + SizedBox(width: 5.w), + Text( + "Preview", + style: TextStyle( + fontWeight: FontWeight.w400, + color: const Color(0xffF95F62), + fontSize: 13.sp, + ), + ), + ], + ), + ), + ), + ], + ), ); } @@ -382,4 +384,4 @@ class MyPostCardOrdersView extends StatelessWidget { return status; } } -} \ No newline at end of file +} diff --git a/lib/postcard/views/my_postcard_preview_view.dart b/lib/postcard/views/my_postcard_preview_view.dart index eedc6ca..0ae914a 100644 --- a/lib/postcard/views/my_postcard_preview_view.dart +++ b/lib/postcard/views/my_postcard_preview_view.dart @@ -5,6 +5,8 @@ import '../../common_packages/app_bar.dart'; import '../../common_packages/back_widget.dart'; import '../models/my_postcard_model.dart'; import '../../networkApiServices/api_urls.dart'; +import '../widgets/back_card_widget.dart'; +import '../widgets/front_card_widget.dart'; class MyPostcardPreviewView extends StatefulWidget { final MyPostCard postcard; @@ -40,59 +42,67 @@ class _MyPostcardPreviewViewState extends State { SizedBox(height: 29.h), // Postcard Number with Action Icons - Padding( - padding: EdgeInsets.symmetric(horizontal: 20.w), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "#${widget.postcard.pcNumber}", + Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w), + child: Row( + children: [ + /// 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, ), ), - Row( - children: [ - GestureDetector( - onTap: () { - // Delete functionality - }, - child: Image.asset( - 'assets/icons/delete_icon.png', - width: 24, - height: 24, - ), + ), + + SizedBox(width: 12.w), + + /// Action Icons + Row( + children: [ + GestureDetector( + onTap: () { + // Delete functionality + }, + child: Image.asset( + 'assets/icons/delete_icon.png', + width: 24, + height: 24, ), - SizedBox(width: 16.w), - GestureDetector( - onTap: () { - // Edit functionality - }, - child: Image.asset( - 'assets/icons/edit_icon.png', - width: 24, - height: 24, - ), + ), + SizedBox(width: 16.w), + GestureDetector( + onTap: () { + // Edit functionality + }, + child: Image.asset( + 'assets/icons/edit_icon.png', + width: 24, + height: 24, ), - SizedBox(width: 16.w), - GestureDetector( - onTap: () { - // Send functionality - }, - child: Image.asset( - 'assets/icons/send_icon.png', - width: 24, - height: 24, - ), + ), + SizedBox(width: 16.w), + GestureDetector( + onTap: () { + // Send functionality + }, + child: Image.asset( + 'assets/icons/send_icon.png', + width: 24, + height: 24, ), - ], - ), - ], - ), + ), + ], + ), + ], ), - SizedBox(height: 20.h), + ), + SizedBox(height: 20.h), // Flip buttons Padding( @@ -110,14 +120,14 @@ class _MyPostcardPreviewViewState extends State { 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, ), @@ -136,7 +146,7 @@ class _MyPostcardPreviewViewState extends State { 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, ), @@ -144,7 +154,7 @@ class _MyPostcardPreviewViewState extends State { 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, ), ], @@ -154,21 +164,54 @@ class _MyPostcardPreviewViewState extends State { ), ), - // Postcard Display Expanded( - child: Center( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition( - opacity: animation, - child: child, - ); - }, - child: showBack ? _buildBackSide() : _buildFrontSide(), + child: Padding( + padding: EdgeInsets.only(top: 40.h), + child: Align( + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + child: showBack + ? BackCardWidget( + key: const ValueKey('back'), + message: widget.postcard.pcContent, + city: widget.postcard.cityName, + state: widget.postcard.stateName, + country: widget.postcard.countryName, + address: widget.postcard.address1, + name: widget.postcard.fullname, + pincode: widget.postcard.zipCode, + ) + : FrontCardWidget( + key: const ValueKey('front'), + imageUrl: + '${ApiUrls.baseUrl}${widget.postcard.pcImagePath}', + ), + ), ), ), ), + // Expanded( + // child: Padding( + // padding: EdgeInsets.only(top: 40.h), + // child: AnimatedSwitcher( + // duration: const Duration(milliseconds: 400), + // transitionBuilder: (Widget child, Animation animation) { + // return FadeTransition( + // opacity: animation, + // child: child, + // ); + // }, + // child: showBack ? _buildBackSide() : _buildFrontSide(), + // ), + // ), + // ), SizedBox(height: 40.h), ], ), @@ -180,16 +223,8 @@ class _MyPostcardPreviewViewState extends State { Widget _buildFrontSide() { return Container( key: const ValueKey('front'), - margin: EdgeInsets.symmetric(horizontal: 20.w), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), @@ -234,7 +269,6 @@ class _MyPostcardPreviewViewState extends State { Widget _buildBackSide() { return Container( key: const ValueKey('back'), - margin: EdgeInsets.symmetric(horizontal: 20.w), decoration: BoxDecoration( gradient: const LinearGradient( colors: [ @@ -250,13 +284,6 @@ class _MyPostcardPreviewViewState extends State { color: const Color(0xff000000).withOpacity(0.12), width: 1, ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], ), child: AspectRatio( aspectRatio: 1.5, diff --git a/lib/postcard/views/order_success_page_view.dart b/lib/postcard/views/order_success_page_view.dart index 41874e8..c581983 100644 --- a/lib/postcard/views/order_success_page_view.dart +++ b/lib/postcard/views/order_success_page_view.dart @@ -1,5 +1,7 @@ import 'dart:io'; import 'package:citycards_customer/postcard/blocs/postcard_creation_events.dart'; +import 'package:citycards_customer/postcard/widgets/back_card_widget.dart'; +import 'package:citycards_customer/postcard/widgets/front_card_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -56,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), @@ -82,9 +84,17 @@ class OrderSuccessPageView extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 30), child: Transform.rotate( angle: 0.20, - child: MessageCardWidget( + child: BackCardWidget( message: state.message ?? "", + 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, ), ), ), @@ -93,10 +103,11 @@ class OrderSuccessPageView extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 30), child: Transform.rotate( angle: -0.15, - child: PostCardPreviewWidget( - imagePath: state.imagePath ?? "", - message: state.message ?? "", - selectedFont: state.selectedFont, + child: FrontCardWidget( + key: const ValueKey('front'), + imageUrl: state.imagePath ?? "", + // message: state.message ?? "", + // selectedFont: state.selectedFont, ), ), ), diff --git a/lib/postcard/views/postcard_checkout_page_view.dart b/lib/postcard/views/postcard_checkout_page_view.dart index 9617aff..99d7f40 100644 --- a/lib/postcard/views/postcard_checkout_page_view.dart +++ b/lib/postcard/views/postcard_checkout_page_view.dart @@ -1,5 +1,7 @@ import 'dart:io'; import 'package:citycards_customer/postcard/views/my_postcards_view.dart'; +import 'package:citycards_customer/postcard/widgets/back_card_widget.dart'; +import 'package:citycards_customer/postcard/widgets/front_card_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -39,6 +41,7 @@ class PostcardCheckoutPageView extends StatefulWidget { final double baseAmount; final double totalTaxAmount; final double totalAmount; + final int? postcardId; const PostcardCheckoutPageView({ super.key, @@ -59,6 +62,7 @@ class PostcardCheckoutPageView extends StatefulWidget { required this.baseAmount, required this.totalTaxAmount, required this.totalAmount, + required this.postcardId, }); @override @@ -78,17 +82,17 @@ class _PostcardCheckoutPageViewState extends State { if (creationState.imagePath != null && creationState.imagePath!.isNotEmpty) { imageFile = File(creationState.imagePath!); } - + final postcardBloc = context.read(); context.read().add( UpdateCheckoutDataEvent( countryName: widget.countryName, cityName: widget.cityName, stateName: widget.stateName, - zipCode: widget.zipCode, - address1: widget.address1, + zipCode: creationState.zipCode, + address1: creationState.address, address2: widget.address2, pcTitle: widget.pcTitle, - pcContent: creationState.message ?? '', + pcContent: postcardBloc.getFormattedMessage(), pcImageFile: imageFile, pcNumber: widget.pcNumber, pcDatetime: widget.pcDatetime, @@ -100,6 +104,7 @@ class _PostcardCheckoutPageViewState extends State { baseAmount: widget.baseAmount, totalTaxAmount: widget.totalTaxAmount, totalAmount: widget.totalAmount, + postcardId: widget.postcardId, ), ); }); @@ -300,13 +305,13 @@ class _PostcardCheckoutPageViewState extends State { ), ); - final bloc = context.read(); - bloc.add( - ConfirmPaymentEvent( - stripeStatus: 'requires_payment_method', - paymentStatus: 'failed', - ), - ); + // final bloc = context.read(); + // bloc.add( + // ConfirmPaymentEvent( + // stripeStatus: 'requires_payment_method', + // paymentStatus: 'failed', + // ), + // ); } } @@ -380,7 +385,27 @@ class _PostcardCheckoutPageViewState extends State { isProfilePage: false, showDivider: true, ), - // Header + GestureDetector( + onTap: () { + context.read().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: [ @@ -416,15 +441,24 @@ class _PostcardCheckoutPageViewState extends State { const SizedBox(height: 16), - MessageCardWidget( + BackCardWidget( message: creationState.message ?? "", + state: widget.stateName, + country: widget.countryName, + city: widget.cityName, + address: creationState.address, + name: widget.fullname, + pincode: widget.zipCode, selectedFont: creationState.selectedFont, + key: const ValueKey('back'), + // selectedFont: creationState.selectedFont, ), - SizedBox(height: 10.h), - PostCardPreviewWidget( - imagePath: creationState.imagePath ?? "", - message: creationState.message ?? "", - selectedFont: creationState.selectedFont, + SizedBox(height: 20.h), + FrontCardWidget( + imageUrl: creationState.imagePath ?? "", + key: const ValueKey('front'), + // message: creationState.message ?? "", + // selectedFont: creationState.selectedFont, ), SizedBox(height: 60.h), @@ -441,14 +475,14 @@ class _PostcardCheckoutPageViewState extends State { Divider(color: Color(0xffEDEDED)), const SizedBox(height: 5), - _buildPaymentRow( - "Subtotal", "\$ ${widget.baseAmount.toStringAsFixed(2)}"), - const SizedBox(height: 20), - _buildPaymentRow( - "Discount", "\$ ${widget.totalTaxAmount.toStringAsFixed(2)}", - highlight: true), - const SizedBox(height: 8), - Divider(color: Colors.black), + // _buildPaymentRow( + // "Subtotal", "\$ ${widget.baseAmount.toStringAsFixed(2)}"), + // const SizedBox(height: 20), + // _buildPaymentRow( + // "Discount", "\$ ${widget.totalTaxAmount.toStringAsFixed(2)}", + // highlight: true), + // const SizedBox(height: 8), + // Divider(color: Colors.black), _buildPaymentRow( "Grand Total", "\$ ${widget.totalAmount.toStringAsFixed(2)}", size: 20.sp), @@ -465,7 +499,7 @@ class _PostcardCheckoutPageViewState extends State { const SizedBox(width: 10), Expanded( child: Text( - "${widget.cityName}, ${widget.stateName}, ${widget.countryName} ${widget.zipCode}", + "${creationState.address},${widget.cityName}, ${widget.stateName}, ${widget.countryName} ${widget.zipCode}", style: GoogleFonts.poppins( fontSize: 13.sp, color: const Color(0xff2D3134), diff --git a/lib/postcard/views/postcard_creation_page_view.dart b/lib/postcard/views/postcard_creation_page_view.dart index b241ada..92b535c 100644 --- a/lib/postcard/views/postcard_creation_page_view.dart +++ b/lib/postcard/views/postcard_creation_page_view.dart @@ -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( - create: (_) => PostcardCreationBloc(), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => PostcardCreationBloc(), + ), + BlocProvider( + create: (_) => AddToCartPostCardBloc( + AddToCartPostCardRepository(), + ), + ), + ], child: BlocBuilder( 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().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; @@ -74,7 +119,7 @@ class PostcardCreationPage extends StatelessWidget { break; case PostcardStep.myOrderPostcardPreview: stepWidget = const OrderPostcardPreviewPageView(); - } + } return Scaffold( backgroundColor: Colors.white, @@ -84,4 +129,4 @@ class PostcardCreationPage extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/postcard/views/postcard_purchase_form_page_view.dart b/lib/postcard/views/postcard_purchase_form_page_view.dart index 461c79d..04bb216 100644 --- a/lib/postcard/views/postcard_purchase_form_page_view.dart +++ b/lib/postcard/views/postcard_purchase_form_page_view.dart @@ -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 createState() => _PostcardPurchaseFormPageViewState(); @@ -23,18 +46,34 @@ class _PostcardPurchaseFormPageViewState extends State( builder: (context, state) { - final bloc = context.read(); + final creationBloc = context.read(); - return SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CommonAppBar( - isWhiteLogo: false, - isProfilePage: false, - showDivider: true, - ), + return BlocListener( + listener: (context, cartState) { + if (cartState is AddToCartPostCardSuccess) { + // Update the postcard number in creation bloc + creationBloc.add(UpdatePostcardNumber(cartState.pcNumber)); - // Order ID - Text( - "#78895436", - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.w600, - color: const Color(0xff1A1A1A), + // 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( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CommonAppBar( + isWhiteLogo: false, + isProfilePage: false, + showDivider: true, ), - ), - const SizedBox(height: 20), - - // Postcard image + title - Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: state.imagePath != null - ? Image.file( - File(state.imagePath!), - height: 70, - width: 70, - fit: BoxFit.cover, - ) - : Container( - height: 70, - width: 70, - color: const Color(0xffFEE7E7), - child: const Icon(Icons.image_outlined, - color: Color(0xffFDCDCE)), + GestureDetector( + onTap: () { + context.read().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, + ), + ), + ], ), ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( - controller: _titleController, - decoration: InputDecoration( - hintText: "Add title", - hintStyle: GoogleFonts.poppins( - color: const Color(0xff999999), fontSize: 14.sp), - enabledBorder: const UnderlineInputBorder( - borderSide: - BorderSide(color: Color(0xffFDCDCE), width: 1), + ), + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: state.imagePath != null + ? Image.file( + File(state.imagePath!), + height: 70, + width: 70, + fit: BoxFit.cover, + ) + : Container( + height: 70, + width: 70, + color: const Color(0xffFEE7E7), + child: const Icon(Icons.image_outlined, + color: Color(0xffFDCDCE)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _titleController, + decoration: InputDecoration( + hintText: "Add title", + hintStyle: GoogleFonts.poppins( + color: const Color(0xff999999), fontSize: 14.sp), + enabledBorder: const UnderlineInputBorder( + borderSide: + BorderSide(color: Color(0xffFDCDCE), width: 1), + ), + focusedBorder: const UnderlineInputBorder( + borderSide: + BorderSide(color: Color(0xffFDCDCE), width: 1), + ), ), - focusedBorder: const UnderlineInputBorder( - borderSide: - BorderSide(color: Color(0xffFDCDCE), width: 1), + style: GoogleFonts.poppins(fontSize: 14.sp), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a title'; + } + return null; + }, + ), + ), + ], + ), + + const SizedBox(height: 28), + + // Personal details section + Text( + "Recipient Details", + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: const Color(0xff1A1A1A), + ), + ), + const SizedBox(height: 16), + + _buildInputField( + label: "Recipient", + hint: "Enter the recipient's name", + controller: _fullNameController, + ), + _buildInputField( + label: "Email", + hint: "eg: Jay@gmail.com", + controller: _emailController, + keyboardType: TextInputType.emailAddress, + ), + _buildInputField( + label: "Phone number", + hint: "eg: +91 9999 999 999", + controller: _phoneController, + keyboardType: TextInputType.phone, + ), + _buildInputField( + label: "Address", + hint: "Enter the recipient's Address", + controller: _addressController, + ), + _buildInputField( + label: "City", + hint: "Enter the name of your city", + controller: _cityController, + ), + _buildDropdownField( + label: "State", + hint: "Select your state", + value: _selectedState, + onChanged: (val) { + setState(() { + _selectedState = val; + }); + }, + ), + _buildInputField( + label: "Zip Code", + hint: "Enter the Zip Code you reside in", + controller: _zipCodeController, + keyboardType: TextInputType.number, + ), + _buildDropdownField( + label: "Country", + hint: "Select your country", + value: _selectedCountry, + onChanged: (val) { + setState(() { + _selectedCountry = val; + }); + }, + ), + + const SizedBox(height: 24), + + // Next button + BlocBuilder( + builder: (context, cartState) { + final isLoading = cartState is AddToCartPostCardLoading; + final addToCartBloc = context.read(); + + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isLoading + ? null + : () { + creationBloc.add( + UpdatePurchaseFormData( + pcTitle: _titleController.text, + fullName: _fullNameController.text, + emailId: _emailController.text, + phoneNumber: _phoneController.text, + address: _addressController.text, + city: _cityController.text, + state: _selectedState, + zipCode: _zipCodeController.text, + country: _selectedCountry, + ), + ); + if (_formKey.currentState!.validate()) { + final currentDate = DateFormat('yyyy-MM-dd').format(DateTime.now()); + + 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( + backgroundColor: const Color(0xffF95F62), + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(40), + ), + ), + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + "Next", + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), ), ), - style: GoogleFonts.poppins(fontSize: 14.sp), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a title'; - } - return null; - }, - ), - ), - ], - ), - - const SizedBox(height: 28), - - // Personal details section - Text( - "Add personal details", - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: const Color(0xff1A1A1A), - ), - ), - const SizedBox(height: 16), - - _buildInputField( - label: "Full Name", - hint: "Lorem Ipsum", - controller: _fullNameController, - ), - _buildInputField( - label: "Email ID", - hint: "Lorem@gmail.com", - icon: Icons.email_outlined, - controller: _emailController, - keyboardType: TextInputType.emailAddress, - ), - _buildInputField( - label: "Phone number", - hint: "+91 9999 999 999", - icon: Icons.phone_outlined, - controller: _phoneController, - keyboardType: TextInputType.phone, - ), - - const SizedBox(height: 28), - - // Address details section - Text( - "Add address details", - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: const Color(0xff1A1A1A), - ), - ), - const SizedBox(height: 16), - - _buildInputField( - label: "City", - hint: "Lorem Ipsum", - controller: _cityController, - ), - _buildDropdownField( - label: "Country", - hint: "Lorem Ipsum", - value: _selectedCountry, - onChanged: (val) { - setState(() { - _selectedCountry = val; - }); - }, - ), - _buildDropdownField( - label: "State", - hint: "Lorem Ipsum", - value: _selectedState, - onChanged: (val) { - setState(() { - _selectedState = val; - }); - }, - ), - _buildInputField( - label: "Zip Code", - hint: "000000", - controller: _zipCodeController, - keyboardType: TextInputType.number, - ), - - const SizedBox(height: 30), - - // Next Button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - // Update the bloc with form data - bloc.add(UpdatePurchaseFormData( - pcTitle: _titleController.text, - fullName: _fullNameController.text, - emailId: _emailController.text, - phoneNumber: _phoneController.text, - city: _cityController.text, - country: _selectedCountry ?? '', - state: _selectedState ?? '', - zipCode: _zipCodeController.text, - )); - - // Navigate to next step - bloc.add(GoToNextStep()); - } + ); }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xffF95F62), - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(40), - ), - ), - child: Text( - "Next", - style: TextStyle( - color: Colors.white, - fontSize: 14.sp, - fontWeight: FontWeight.w600, - ), - ), ), - ), - ], + ], + ), ), ), ), @@ -368,9 +461,23 @@ class _PostcardPurchaseFormPageViewState extends State().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", @@ -92,14 +115,21 @@ class _PreviewPostcardStepPageViewState extends State { TextPosition(offset: _controller.text.length)); final fonts = [ - {"name": "Default", "font": GoogleFonts.poppins()}, - {"name": "Classic", "font": GoogleFonts.playfairDisplay()}, - {"name": "Handwriting", "font": GoogleFonts.dancingScript()}, - {"name": "Elegant", "font": GoogleFonts.cormorantGaramond()}, + {"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( @@ -59,7 +62,27 @@ class _WriteMessageStepPageViewState extends State { children: [ CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,), StepProgressBar(totalSteps: 4, currentStep: 3), - const SizedBox(height: 24), + GestureDetector( + onTap: () { + context.read().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("Write a message", style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), @@ -91,12 +114,7 @@ class _WriteMessageStepPageViewState extends State { maxLines: 8, maxLength: 400, cursorColor: const Color(0xffF95F62), - style: TextStyle( - fontFamily: state.selectedFont ?? - GoogleFonts.poppins().fontFamily, - fontSize: 14.sp, - color: Colors.black, - ), + style: _getTextFieldStyle(state.selectedFont, fonts), decoration: InputDecoration( border: InputBorder.none, hintText: "Add Your Message Here", @@ -133,43 +151,52 @@ class _WriteMessageStepPageViewState extends State { children: fonts.map((font) { final TextStyle fontStyle = font['font'] as TextStyle; final String fontName = font["name"] as String; - final isSelected = state.selectedFont == - fontStyle.fontFamily || - (state.selectedFont == null && - fontName == "Default"); + final String cleanName = font["cleanName"] as String; + + final isSelected = state.selectedFont == cleanName || + (state.selectedFont == null && fontName == "Default"); return GestureDetector( - onTap: () => bloc - .add(ChangeFontStyle(fontStyle.fontFamily ?? "")), + onTap: () => bloc.add(ChangeFontStyle(cleanName)), child: Container( - margin: const EdgeInsets.only(right: 12), - padding: const EdgeInsets.all(12), - width: 90.w, + padding: const EdgeInsets.all(6), + width: 100.w, + height: 100.h, decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all( + ), + child: CustomPaint( + painter: DottedBorderPainter( color: isSelected ? const Color(0xffF95F62) : const Color(0xffE0E0E0), - width: 1.5, + strokeWidth: 1.5, + dashWidth: 4, + dashSpace: 3, + borderRadius: 12, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Aa", + style: fontStyle.copyWith( + fontSize: 24.sp, + color: const Color(0xff1A1A1A), + )), + const SizedBox(height: 4), + Text(fontName, + textAlign: TextAlign.center, + style: fontStyle.copyWith( + fontSize: 11.sp, + color: isSelected + ? const Color(0xffF95F62) + : const Color(0xff2D3134), + )), + ], + ), ), - ), - child: Column( - children: [ - Text("Aa", - style: fontStyle.copyWith( - fontSize: 20.sp, - color: const Color(0xff1A1A1A), - )), - const SizedBox(height: 4), - Text(fontName, - style: TextStyle( - fontSize: 12.sp, - color: isSelected - ? const Color(0xffF95F62) - : const Color(0xff2D3134), - )), - ], ), ), ); @@ -209,5 +236,117 @@ class _WriteMessageStepPageViewState extends State { }, ); } + + // Helper method to get the correct font style for the text field + TextStyle _getTextFieldStyle(String? selectedFont, List> fonts) { + if (selectedFont == null || selectedFont.isEmpty) { + return GoogleFonts.poppins(fontSize: 14.sp, color: Colors.black); + } + + // Find matching font by cleanName + for (var font in fonts) { + if (font['cleanName'] == selectedFont) { + final TextStyle fontStyle = font['font'] as TextStyle; + return fontStyle.copyWith(fontSize: 14.sp, color: Colors.black); + } + } + + // Default fallback to Poppins + return GoogleFonts.poppins(fontSize: 14.sp, color: Colors.black); + } } +// Custom Painter for Dotted Border +class DottedBorderPainter extends CustomPainter { + final Color color; + final double strokeWidth; + final double dashWidth; + final double dashSpace; + final double borderRadius; + + DottedBorderPainter({ + required this.color, + this.strokeWidth = 1.5, + this.dashWidth = 4, + this.dashSpace = 3, + this.borderRadius = 12, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + final path = Path() + ..addRRect(RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + Radius.circular(borderRadius), + )); + + // Create dashed path + final dashPath = _createDashedPath(path, dashWidth, dashSpace); + canvas.drawPath(dashPath, paint); + } + + Path _createDashedPath(Path source, double dashWidth, double dashSpace) { + final Path dest = Path(); + for (final PathMetric metric in source.computeMetrics()) { + double distance = 0.0; + bool draw = true; + while (distance < metric.length) { + final double length = draw ? dashWidth : dashSpace; + if (distance + length > metric.length) { + if (draw) { + dest.addPath( + metric.extractPath(distance, metric.length), + Offset.zero, + ); + } + break; + } else { + if (draw) { + dest.addPath( + metric.extractPath(distance, distance + length), + Offset.zero, + ); + } + distance += length; + draw = !draw; + } + } + } + return dest; + } + + @override + bool shouldRepaint(DottedBorderPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.dashWidth != dashWidth || + oldDelegate.dashSpace != dashSpace; + } +} + +// Lined Paper Painter (assuming this exists in your original code) +class LinedPaperPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = const Color(0xFFE0E0E0) + ..strokeWidth = 1; + + const lineSpacing = 30.0; + for (double i = lineSpacing; i < size.height; i += lineSpacing) { + canvas.drawLine( + Offset(0, i), + Offset(size.width, i), + paint, + ); + } + } + + @override + bool shouldRepaint(LinedPaperPainter oldDelegate) => false; +} \ No newline at end of file diff --git a/lib/postcard/widgets/back_card_widget.dart b/lib/postcard/widgets/back_card_widget.dart index dc55b68..1db35ed 100644 --- a/lib/postcard/widgets/back_card_widget.dart +++ b/lib/postcard/widgets/back_card_widget.dart @@ -1,27 +1,113 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:google_fonts/google_fonts.dart'; +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; final String state; final String country; + final String pincode; final double aspectRatio; final double scale; const BackCardWidget({ super.key, this.message = '', + this.selectedFont, + this.name = '', + this.address = '', this.city = '', this.state = '', this.country = '', + this.pincode = '', this.aspectRatio = 1.5, this.scale = 1.08, }); + // Parse HTML message and extract font family and text + Map _parseHtmlMessage(String htmlMessage) { + if (htmlMessage.isEmpty) { + return {'text': '', 'fontFamily': ''}; + } + + // Check if message contains HTML tags + if (!htmlMessage.contains(' createState() => _EditMessageState(); +} + +class _EditMessageState extends State { + final TextEditingController _controller = TextEditingController(); + 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", + }, + ]; + + String selectedFont = "Poppins"; + + @override + void initState() { + final parsedMessage = _parseHtmlMessage(widget.text); + final messageText = parsedMessage['text'] ?? ''; + final fontFamily = parsedMessage['fontFamily'] ?? ''; + setState(() { + _controller.text = messageText; + selectedFont = fontFamily; + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF5F5), + borderRadius: BorderRadius.circular(12), + ), + child: CustomPaint( + painter: LinedPaperPainter(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: TextField( + controller: _controller, + maxLines: 8, + maxLength: 400, + cursorColor: const Color(0xffF95F62), + style: _getTextFieldStyle(selectedFont, fonts), + decoration: InputDecoration( + border: InputBorder.none, + hintText: "Add Your Message Here", + hintStyle: TextStyle( + color: const Color(0xff999999), + fontSize: 14.sp, + ), + counterText: "", + ), + onChanged: (val) { + widget.onChange(val, selectedFont); + }, + ), + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(top: 6, right: 8), + child: Text( + "${_controller.text.length}/400", + style: TextStyle(fontSize: 12.sp, color: const Color(0xff999999)), + ), + ), + ), + + const SizedBox(height: 20), + + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: fonts.map((font) { + final TextStyle fontStyle = font['font'] as TextStyle; + final String fontName = font["name"] as String; + final String cleanName = font["cleanName"] as String; + + final isSelected = selectedFont == cleanName; + + return GestureDetector( + onTap: () { + setState(() { + selectedFont = cleanName; + }); + widget.onChange(_controller.text, selectedFont); + }, + child: Container( + padding: const EdgeInsets.all(6), + width: 100.w, + height: 100.h, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: CustomPaint( + painter: DottedBorderPainter( + color: isSelected + ? const Color(0xffF95F62) + : const Color(0xffE0E0E0), + strokeWidth: 1.5, + dashWidth: 4, + dashSpace: 3, + borderRadius: 12, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Aa", + style: fontStyle.copyWith( + fontSize: 24.sp, + color: const Color(0xff1A1A1A), + ), + ), + const SizedBox(height: 4), + Text( + fontName, + textAlign: TextAlign.center, + style: fontStyle.copyWith( + fontSize: 11.sp, + color: isSelected + ? const Color(0xffF95F62) + : const Color(0xff2D3134), + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } + + TextStyle _getTextFieldStyle( + String? selectedFont, + List> fonts, + ) { + if (selectedFont == null || selectedFont.isEmpty) { + return GoogleFonts.poppins(fontSize: 14.sp, color: Colors.black); + } + + // Find matching font by cleanName + for (var font in fonts) { + if (font['cleanName'] == selectedFont) { + final TextStyle fontStyle = font['font'] as TextStyle; + return fontStyle.copyWith(fontSize: 14.sp, color: Colors.black); + } + } + + // Default fallback to Poppins + return GoogleFonts.poppins(fontSize: 14.sp, color: Colors.black); + } + + Map _parseHtmlMessage(String htmlMessage) { + if (htmlMessage.isEmpty) { + return {'text': '', 'fontFamily': ''}; + } + + // Check if message contains HTML tags + if (!htmlMessage.contains(' formKey; + final Function(String) selectState; + final Function(String) selectCountry; + const EditYourdetails({ + super.key, + required this.fullNameController, + required this.addressController, + required this.cityController, + required this.zipCodeController, + required this.selectedCountry, + required this.selectedState, + required this.formKey, + required this.selectState, + required this.selectCountry, + }); + + @override + State createState() => _EditYourdetailsState(); +} + +class _EditYourdetailsState extends State { + String? _selectedState; + String? _selectedCountry; + + final List countries = [ + 'Australia', + ]; + + final List states = [ + 'New South Wales', + 'Victoria', + 'Queensland', + 'South Australia', + 'Western Australia', + 'Tasmania', + 'Northern Territory', + 'Australian Capital Territory', + ]; + + @override + void initState() { + setState(() { + _selectedState = widget.selectedState; + _selectedCountry = widget.selectedCountry; + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Recipient Details", + style: GoogleFonts.poppins( + color: Color(0XFF212121), + fontSize: 18.sp, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 2.h), + Text( + "Enter the address of the person who will receive this postcard", + style: GoogleFonts.poppins( + color: Color(0XFF000000).withValues(alpha: 0.6), + fontSize: 14.sp, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 16), + + _buildInputField( + label: "Recipient", + hint: "Enter the recipient's name", + controller: widget.fullNameController, + ), + _buildInputField( + label: "Address", + hint: "Enter the recipient's Address", + controller: widget.addressController, + ), + _buildInputField( + label: "City", + 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(val!); + }, + ), + _buildInputField( + label: "Zip Code", + hint: "Enter the Zip Code you reside in", + controller: widget.zipCodeController, + keyboardType: TextInputType.number, + ), + ], + ); + } + + Widget _buildInputField({ + required String label, + required String hint, + required TextEditingController controller, + IconData? icon, + TextInputType? keyboardType, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.poppins( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: const Color(0xff1A1A1A), + ), + ), + const SizedBox(height: 6), + TextFormField( + controller: controller, + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + hintStyle: GoogleFonts.poppins( + color: const Color(0xff999999), + fontSize: 14.sp, + ), + suffixIcon: icon != null + ? Icon(icon, color: Colors.black, size: 20) + : null, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 12, + ), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Color(0xffFDCDCE)), + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Color(0xffFDCDCE)), + borderRadius: BorderRadius.circular(8), + ), + errorBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.red), + borderRadius: BorderRadius.circular(8), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.red), + borderRadius: BorderRadius.circular(8), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter $label'; + } + if (label == "Email ID" && !value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + ], + ), + ); + } + + Widget _buildDropdownField({ + required String label, + required String hint, + required String? value, + required List items, + required Function(String?) onChanged, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.poppins( + fontSize: 13.sp, + fontWeight: FontWeight.w500, + color: const Color(0xff1A1A1A), + ), + ), + const SizedBox(height: 6), + DropdownButtonFormField( + value: value, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 12, + ), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Color(0xffFDCDCE)), + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Color(0xffFDCDCE)), + borderRadius: BorderRadius.circular(8), + ), + errorBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.red), + borderRadius: BorderRadius.circular(8), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.red), + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon( + Icons.keyboard_arrow_down, + color: Color(0xffFDCDCE), + ), + hint: Text( + hint, + style: GoogleFonts.poppins( + color: const Color(0xff999999), + fontSize: 14.sp, + ), + ), + items: items.map((String item) { + return DropdownMenuItem( + value: item, + child: Text(item), + ); + }).toList(), + onChanged: onChanged, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please select $label'; + } + return null; + }, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/postcard/widgets/front_card_widget.dart b/lib/postcard/widgets/front_card_widget.dart index 1a6977b..9cf8377 100644 --- a/lib/postcard/widgets/front_card_widget.dart +++ b/lib/postcard/widgets/front_card_widget.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -11,6 +12,12 @@ class FrontCardWidget extends StatelessWidget { this.aspectRatio = 1.5, }); + /// Automatically detect if the path is a network URL or local file path + bool get _isNetworkImage { + return imageUrl.startsWith('http://') || + imageUrl.startsWith('https://'); + } + @override Widget build(BuildContext context) { return Container( @@ -30,19 +37,62 @@ class FrontCardWidget extends StatelessWidget { borderRadius: BorderRadius.circular(6), child: AspectRatio( aspectRatio: aspectRatio, - child: imageUrl.isEmpty - ? Container( - color: Colors.grey.shade200, - child: const Center( - child: Icon(Icons.image, size: 40, color: Colors.grey), - ), - ) - : Image.network( - imageUrl, - fit: BoxFit.cover, - ), + child: _buildImage(), ), ), ); } -} + + Widget _buildImage() { + if (imageUrl.isEmpty) { + return Container( + color: Colors.grey.shade200, + child: const Center( + child: Icon(Icons.image, size: 40, color: Colors.grey), + ), + ); + } + + if (_isNetworkImage) { + return Image.network( + imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey.shade200, + child: const Center( + child: Icon(Icons.broken_image, size: 40, color: Colors.grey), + ), + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ); + } else { + return Image.file( + File(imageUrl), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey.shade200, + child: const Center( + child: Icon(Icons.broken_image, size: 40, color: Colors.grey), + ), + ); + }, + ); + } + } +} \ No newline at end of file diff --git a/lib/postcard/widgets/purchase_details_bottom_sheet.dart b/lib/postcard/widgets/purchase_details_bottom_sheet.dart index 9f3f696..fefa473 100644 --- a/lib/postcard/widgets/purchase_details_bottom_sheet.dart +++ b/lib/postcard/widgets/purchase_details_bottom_sheet.dart @@ -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()); }, diff --git a/lib/profile/bloc/profile/profile_bloc.dart b/lib/profile/bloc/profile/profile_bloc.dart index 4a3a6e3..afc961e 100644 --- a/lib/profile/bloc/profile/profile_bloc.dart +++ b/lib/profile/bloc/profile/profile_bloc.dart @@ -26,7 +26,6 @@ class ProfileBloc extends Bloc { Emitter emit, ) async { try { - emit(const ProfileLoading()); final profile = await _profileRepository.fetchUserProfile(); @@ -54,6 +53,12 @@ class ProfileBloc extends Bloc { 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}'); diff --git a/lib/profile/bloc/profile/profile_event.dart b/lib/profile/bloc/profile/profile_event.dart index 3ec20c4..bd10d29 100644 --- a/lib/profile/bloc/profile/profile_event.dart +++ b/lib/profile/bloc/profile/profile_event.dart @@ -18,6 +18,7 @@ class FetchProfileEvent extends ProfileEvent { List 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 }; } } diff --git a/lib/profile/repository/profile_repository.dart b/lib/profile/repository/profile_repository.dart index cbd035e..bf9cb48 100644 --- a/lib/profile/repository/profile_repository.dart +++ b/lib/profile/repository/profile_repository.dart @@ -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 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 updateUserProfile({ required Map 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,48 +93,38 @@ 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); } -} \ No newline at end of file +} diff --git a/lib/profile/view/edit_profile/edit_profile_view.dart b/lib/profile/view/edit_profile/edit_profile_view.dart index b58ca4c..4eb27c1 100644 --- a/lib/profile/view/edit_profile/edit_profile_view.dart +++ b/lib/profile/view/edit_profile/edit_profile_view.dart @@ -30,6 +30,12 @@ class _EditProfilePageState extends State { 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(); final ImagePicker _picker = ImagePicker(); @@ -64,6 +70,14 @@ class _EditProfilePageState extends State { 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 { 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 { phoneController.dispose(); address1Controller.dispose(); address2Controller.dispose(); + cityController.dispose(); + zipCodeController.dispose(); super.dispose(); } @@ -495,7 +520,7 @@ class _EditProfilePageState extends State { 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 { ), ), + 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( + 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( + 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( + 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( + 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 diff --git a/lib/search_offers/model/offers_model.dart b/lib/search_offers/model/offers_model.dart index f7d7ef2..e3ae508 100644 --- a/lib/search_offers/model/offers_model.dart +++ b/lib/search_offers/model/offers_model.dart @@ -73,7 +73,7 @@ class Offer { factory Offer.fromJson(Map 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 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 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 json) { return CardType( - id: json['id'], + id: json['id'] ?? 0, displayName: json['displayName'] ?? '', ); } @@ -219,7 +219,7 @@ class Category { factory Category.fromJson(Map json) { return Category( - id: json['id'], + id: json['id'] ?? 0, categoryName: json['categoryName'] ?? '', ); } @@ -230,4 +230,4 @@ class Category { 'categoryName': categoryName, }; } -} +} \ No newline at end of file diff --git a/lib/search_offers/view/search_offers_with_listing.dart b/lib/search_offers/view/search_offers_with_listing.dart index 059201b..f6bfb88 100644 --- a/lib/search_offers/view/search_offers_with_listing.dart +++ b/lib/search_offers/view/search_offers_with_listing.dart @@ -221,12 +221,12 @@ class _OffersScreenState extends State { itemBuilder: (context, index) { final offer = offers[index]; return InkWell( - onTap: () { - Navigator.of(context).pushNamed( - RouteConstants.offerPassDetail, - arguments: offer.id, // ✅ pass offerId - ); - }, + // onTap: () { + // Navigator.of(context).pushNamed( + // RouteConstants.offerPassDetail, + // arguments: offer.id, // ✅ pass offerId + // ); + // }, child: Container( padding: EdgeInsets.symmetric( horizontal: 6.w, diff --git a/pubspec.lock b/pubspec.lock index 7924f45..4b0967f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,13 +34,13 @@ packages: source: hosted version: "2.13.0" bloc: - dependency: transitive + dependency: "direct main" description: name: bloc - sha256: e18b8e7825e9921d67a6d256dba0b6015ece8a577eb0a411845c46a352994d78 + sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 url: "https://pub.dev" source: hosted - version: "9.0.1" + version: "9.2.0" boolean_selector: dependency: transitive description: @@ -49,6 +49,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -105,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csc_picker_plus: + dependency: "direct main" + description: + name: csc_picker_plus + sha256: "105e1989dd7462a504d60af024880918bb2936dbb9c97f46c4bd4923fe011411" + url: "https://pub.dev" + source: hosted + version: "0.0.3" csslib: dependency: transitive description: @@ -262,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_glass_morphism: dependency: "direct main" description: @@ -331,6 +371,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.9.3" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: ea369262929d3cc6ebf9d8a00c196127966f117fe433a5e5cb47fb08008ca203 + url: "https://pub.dev" + source: hosted + version: "4.0.3" flutter_stripe: dependency: "direct main" description: @@ -365,6 +413,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.1" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: "606be036287842d779d7ec4e2f6c9435fc29bbbd3c6da6589710f981d8852895" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: ba810da90d6633cbb82bbab630e5b4a3b7d23503263c00ae7f1ef0316dcae5b9 + url: "https://pub.dev" + source: hosted + version: "4.0.1" + geocoding_ios: + dependency: transitive + description: + name: geocoding_ios + sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" + url: "https://pub.dev" + source: hosted + version: "3.2.0" geolocator: dependency: "direct main" description: @@ -709,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" opentype_dart: dependency: transitive description: @@ -837,6 +925,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" sanitize_html: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 655b564..17fe786 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,11 @@ dependencies: sqflite: ^2.4.2 flutter_map: ^8.2.2 flutter_stripe: ^12.2.0 + geocoding: ^4.0.0 + cached_network_image: ^3.4.1 + bloc: ^9.2.0 + csc_picker_plus: ^0.0.3 + flutter_slidable: ^4.0.3 dev_dependencies: flutter_test: