12 Commits

Author SHA1 Message Date
aeeb1c27e0 first commit 2026-03-24 16:56:07 +05:30
46906b04f4 Merge remote-tracking branch 'origin/raj' into Anuj 2026-02-13 20:06:48 +05:30
mystery012728
48fd7037ea snack bar bug solved 2026-02-13 18:34:00 +05:30
mystery012728
40f0ed3a52 pull taken from shree branch and conflict fixes 2026-02-13 17:13:22 +05:30
mystery012728
b08e2699e9 added my passes and more chnages 2026-02-13 15:27:14 +05:30
Shreeyash Thorat
53264619a8 postcard edit 2026-02-13 15:25:05 +05:30
mystery012728
5d08e07de3 added pass details screen new and updated create account page and more changes... 2026-02-10 19:05:42 +05:30
Shreeyash Thorat
68c3f28d76 itnerary 2026-02-10 15:05:38 +05:30
mystery012728
3a08830cce updated iternary api and updated buy pass flow 2026-02-10 13:58:58 +05:30
mystery012728
0c663bdec7 pull taken of shreeyash and conflict solved 2026-02-10 10:44:19 +05:30
mystery012728
e91d24becc pull taken of shreeyash and conflict solved 2026-02-09 10:55:36 +05:30
Shreeyash Thorat
09726eb4e6 API Integration 2026-02-06 19:34:34 +05:30
137 changed files with 11045 additions and 2632 deletions

BIN
assets/icons/calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 B

BIN
assets/icons/person.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/icons/time.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -7,6 +7,8 @@ PODS:
- flutter_native_splash (2.4.3):
- Flutter
- FlutterAngle (0.0.8)
- geocoding_ios (1.0.5):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
@@ -97,6 +99,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_angle (from `.symlinks/plugins/flutter_angle/darwin`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- geocoding_ios (from `.symlinks/plugins/geocoding_ios/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
@@ -129,6 +132,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_angle/darwin"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
geocoding_ios:
:path: ".symlinks/plugins/geocoding_ios/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
google_maps_flutter_ios:
@@ -155,6 +160,7 @@ SPEC CHECKSUMS:
flutter_angle: fc44e198cea1f07e1a5919bad1484049fab65c96
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
FlutterAngle: c810891af800750361b1d0e7cc944f2338d5ae18
geocoding_ios: eafacae6ad11a1eb56681f7d11df602a5fd49416
geolocator_apple: 66b711889fd333205763b83c9dcf0a57a28c7afd
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
google_maps_flutter_ios: e31555a04d1986ab130f2b9f24b6cdc861acc6d3

View File

@@ -9,21 +9,34 @@ import 'stripe_payment_state.dart';
class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
final StripeService _stripeService;
// 🔒 Flag to prevent re-initialization after success
bool _paymentCompleted = false;
StripePaymentBloc({
StripeService? stripeService,
}) : _stripeService = stripeService ?? StripeService(),
super(const StripePaymentInitial()) {
on<InitiatePayment>(_onInitiatePayment);
on<InitiatePaymentWithClientSecret>(_onInitiatePaymentWithClientSecret);
on<CancelPaymentEvent>(_onCancelPayment);
on<ResetPaymentState>(_onResetPaymentState);
on<RetryPaymentEvent>(_onRetryPayment);
}
Future<void> _onInitiatePayment(
InitiatePayment event,
Emitter<StripePaymentState> emit,
) async {
// 🛑 Prevent re-initialization if payment already completed
if (_paymentCompleted) {
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
return;
}
try {
emit(const StripePaymentLoading());
emit(const StripePaymentLoading(
message: 'Creating payment intent...',
));
/// Stripe expects smallest currency unit
/// USD → cents, INR → paise
@@ -35,6 +48,10 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
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<StripePaymentEvent, StripePaymentState> {
),
);
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<void> _onInitiatePaymentWithClientSecret(
InitiatePaymentWithClientSecret event,
Emitter<StripePaymentState> emit,
) async {
// 🛑 Prevent re-initialization if payment already completed
if (_paymentCompleted) {
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
return;
}
try {
emit(const StripePaymentLoading());
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<StripePaymentEvent, StripePaymentState> {
),
);
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<StripePaymentState> emit,
) {
// Only emit cancelled if not already completed
if (!_paymentCompleted) {
emit(const StripePaymentCancelled(
message: 'Payment cancelled by user',
));
}
}
/// Handle payment retry
Future<void> _onRetryPayment(
RetryPaymentEvent event,
Emitter<StripePaymentState> emit,
) async {
// 🔄 Reset completion flag for retry
_paymentCompleted = false;
// Reset state first
emit(const StripePaymentInitial());
// Then initiate payment again
add(InitiatePaymentWithClientSecret(
clientSecret: event.clientSecret,
));
}
/// Reset payment state back to initial
void _onResetPaymentState(
ResetPaymentState event,
Emitter<StripePaymentState> emit,
) {
// 🔄 Reset completion flag
_paymentCompleted = false;
emit(const StripePaymentInitial());
}
/// Centralized Stripe exception handling
void _handleStripeException(
StripeException e,
Emitter<StripePaymentState> 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<void> close() {
// Reset flag on bloc disposal
_paymentCompleted = false;
return super.close();
}
}

View File

@@ -20,7 +20,7 @@ class InitiatePayment extends StripePaymentEvent {
List<Object?> 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<Object?> 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<Object?> get props => [clientSecret];
}

View File

@@ -7,36 +7,59 @@ abstract class StripePaymentState extends Equatable {
List<Object?> 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<Object?> 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<Object?> get props => [error];
List<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> get props => [message];
}

View File

@@ -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<String, dynamic>;
/// 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<StripePaymentViewContent> createState() =>
_StripePaymentViewContentState();
}
/// 🚀 Static method to show as bottom sheet
static Future<bool?> 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<bool>(
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<StripePaymentViewContent> {
@override
void initState() {
super.initState();
// Automatically initiate payment when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<StripePaymentBloc>().add(
InitiatePayment(
amount: widget.amount,
currency: widget.currency,
),
);
});
/// 🚀 Static method to show as full screen dialog
static Future<bool?> 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<bool>(
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<StripePaymentBloc, StripePaymentState>(
return BlocConsumer<StripePaymentBloc, StripePaymentState>(
// 🔒 CRITICAL: Only listen when state actually changes to prevent duplicate triggers
listenWhen: (previous, current) {
// Don't re-trigger if both states are the same success state
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
debugPrint('⚠️ Preventing duplicate success listener');
return false;
}
return true;
},
listener: (context, state) {
if (state is StripePaymentSuccess) {
// 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<StripePaymentBloc, StripePaymentState>(
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>(
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<StripePaymentBloc>()
.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<Color>(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<StripePaymentBloc>().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;
}
}

View File

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

View File

@@ -26,15 +26,18 @@ class ShareBottomSheet extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// drag handle
Container(
height: 4.h,
width: 47.w,
margin: EdgeInsets.only(bottom: 16),
margin: EdgeInsets.only(bottom: 16.h),
decoration: BoxDecoration(
color: Color(0xFF222222),
color: const Color(0xFF222222),
borderRadius: BorderRadius.circular(8),
),
),
// link field
TextField(
readOnly: true,
decoration: InputDecoration(
@@ -51,7 +54,10 @@ class ShareBottomSheet extends StatelessWidget {
),
),
),
SizedBox(height: 20.h),
// grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -67,7 +73,16 @@ class ShareBottomSheet extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(item['icon']!, width: 55.w),
// FIXED SIZE ICON CONTAINER
Container(
width: 55.w,
height: 55.w,
alignment: Alignment.center,
child: Image.asset(
item['icon']!,
fit: BoxFit.contain,
),
),
SizedBox(height: 8.h),
Text(
item['title']!,
@@ -78,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),
],
),
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
on<FetchCheckoutCouponsEvent>(_onFetchCheckoutCoupons);
on<ApplyCouponEvent>(_onApplyCoupon);
on<RemoveCouponEvent>(_onRemoveCoupon);
on<ApplyCouponToBackendEvent>(_onApplyCouponToBackend); // 🆕 NEW
on<InitiatePaymentEvent>(_onInitiatePayment); // 🆕 NEW
on<ConfirmPaymentEvent>(_onConfirmPayment); // 🆕 NEW
}
@@ -42,13 +43,77 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
}
}
void _onRemoveCoupon(
Future<void> _onRemoveCoupon(
RemoveCouponEvent event,
Emitter<CheckoutState> 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<void> _onApplyCouponToBackend(
ApplyCouponToBackendEvent event,
Emitter<CheckoutState> 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<CheckoutEvent, CheckoutState> {
ConfirmPaymentEvent event,
Emitter<CheckoutState> emit,
) async {
// 🔒 GUARD: Prevent duplicate confirmation calls
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
if (currentState.hasConfirmationBeenSent) {
print('⚠️ [CHECKOUT BLOC] Payment confirmation already sent. Ignoring duplicate call.');
return;
}
}
// Show loading state
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
@@ -139,6 +213,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
isConfirmingPayment: true,
confirmationError: null,
isPaymentConfirmed: false,
hasConfirmationBeenSent: true, // 🔒 Mark as sent
));
} else {
emit(CheckoutPaymentConfirmingState());
@@ -174,6 +249,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
isConfirmingPayment: false,
isPaymentConfirmed: false,
confirmationError: e.toString(),
hasConfirmationBeenSent: false, // 🔓 Reset on error to allow retry
));
} else {
emit(CheckoutPaymentConfirmationErrorState(

View File

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

View File

@@ -10,6 +10,10 @@ class CheckoutCouponsLoadedState extends CheckoutState {
final List<AllCouponsModel> 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<String, dynamic>? 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<AllCouponsModel>? 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<String, dynamic>? 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,
);
}
}

View File

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

View File

@@ -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<CheckoutView> {
}
}
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<void> _handlePaymentFlow(BuildContext context, String clientSecret, int bookingId) async {
// Show payment bottom sheet with BLoC
final paymentResult = await showModalBottomSheet<Map<String, dynamic>>(
/// 🆕 Handle payment flow with client secret - SIMPLIFIED VERSION
Future<void> _handlePaymentFlow(BuildContext context, String clientSecret, int bookingId,double finalTotal) async {
final paymentSuccess = await StripePaymentScreen.showAsBottomSheet(
context: context,
clientSecret: clientSecret,
amount: finalTotal,
currencySymbol: '\$',
title: 'Complete Payment',
loadingMessage: 'Processing your pass payment...',
successMessage: 'Payment Successful!\nYour pass is ready.',
failureMessage: 'Payment Failed',
primaryColor: const Color(0xFFF95F62),
heightRatio: 0.5,
isDismissible: false,
enableDrag: false,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (bottomSheetContext) {
return BlocProvider(
create: (_) => StripePaymentBloc(stripeService: StripeService())
..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)),
child: BlocConsumer<StripePaymentBloc, StripePaymentState>(
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>(
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<CheckoutBloc>().add(
ConfirmPaymentEvent(
bookingId: bookingId,
stripeStatus: 'succeeded',
paymentStatus: 'success',
),
);
},
onPaymentFailure: (error) {
context.read<CheckoutBloc>().add(
ConfirmPaymentEvent(
bookingId: bookingId,
stripeStatus: 'failed',
paymentStatus: 'failed',
),
);
},
onPaymentCancelled: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Payment cancelled'),
backgroundColor: Colors.orange,
),
);
},
);
// 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<CheckoutBloc>().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<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Payment confirmed successfully!'),
backgroundColor: Colors.green,
),
);
context.read<NavigationBloc>().add(NavigationTabChanged(2));
}
} else {
// Payment failed or cancelled - still confirm with backend
if (context.mounted) {
// context.read<CheckoutBloc>().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<CheckoutBloc>().add(
ApplyCouponEvent(
coupon: selectedCoupon),
);
context.read<CheckoutBloc>().add(
ApplyCouponToBackendEvent(
bookingId: widget.bookingId,
couponCode: coupon.couponCode,
),
);
},
),
);
@@ -740,13 +603,16 @@ class _CheckoutContent extends StatelessWidget {
GestureDetector(
onTap: () {
if (appliedCoupon != null) {
context
.read<CheckoutBloc>()
.add(RemoveCouponEvent());
} else if (state.coupons.isNotEmpty) {
context.read<CheckoutBloc>().add(
ApplyCouponEvent(
coupon: state.coupons[0]),
RemoveCouponEvent(bookingId: widget.bookingId),
);
} else if (state.coupons.isNotEmpty) {
// Apply coupon via backend API
context.read<CheckoutBloc>().add(
ApplyCouponToBackendEvent(
bookingId: widget.bookingId,
couponCode: state.coupons[0].couponCode,
),
);
}
},
@@ -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<CheckoutBloc>().add(
InitiatePaymentEvent(
bookingId: bookingId),
bookingId: widget.bookingId),
);
} else {
// Show purchase details bottom sheet
final result = await PassPurchaseBottomSheet.show(
context, bookingId: bookingId);
context, bookingId: widget.bookingId);
// ✅ Handle 'Buy for Myself' - user submitted details
if (result == 'success') {
onPurchaseDetailsChanged(true);
widget.onPurchaseDetailsChanged(true);
}
// ✅ Handle 'Gift the Pass' - navigate to AddDetailsView
else if (result == 'gift') {
final giftResult = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => AddDetailsView(bookingId: bookingId),
builder: (_) => AddDetailsView(bookingId: widget.bookingId),
),
);
// If gift details were successfully submitted, mark as confirmed
if (giftResult == 'success') {
onPurchaseDetailsChanged(true);
widget.onPurchaseDetailsChanged(true);
}
}
}
@@ -915,7 +782,7 @@ class _CheckoutContent extends StatelessWidget {
},
width: double.infinity,
label: isLoggedIn
? (isPurchaseDetailsConfirmed
? (widget.isPurchaseDetailsConfirmed
? (isInitiatingPayment || isConfirmingPayment
? "Processing..."
: "Pay \$${finalTotal.toStringAsFixed(2)}")

View File

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

View File

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

View File

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

View File

@@ -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<String, dynamic> args = settings.arguments as Map<String, dynamic>;
final int cityId = args['cityId'] as int;
final String source = args['source'] as String;
return MaterialPageRoute(
builder: (_) {
return BlocProvider(
create: (_) => MyPassesAttractionsBloc(
repository: MyPassesAttractionsRepository(),
),
child: PassAttractionsPage(
cityXid: cityId,
source: source,
),
);
},
);
case RouteConstants.profile:
return MaterialPageRoute(
builder: (_) {
@@ -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);
},
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,17 +11,25 @@ class CreateAccountRepository {
required String mobileNumber,
required String address1,
required String address2,
required String city,
required String state,
required String country,
required String postalCode,
}) async {
try {
final response = await _apiServices.postApi(
url: ApiUrls.createAccount,
data: {
'firstName': firstName,
'lastName': lastName,
'emailAddress': emailAddress,
'mobileNumber': mobileNumber,
'address1': address1,
'address2': address2,
"firstName": firstName,
"lastName": lastName,
"emailAddress": emailAddress,
"mobileNumber": mobileNumber,
"address1": address1,
"address2": address2,
"city": city,
"state": state,
"country": country,
"postalCode": postalCode,
},
);
@@ -30,4 +38,4 @@ class CreateAccountRepository {
throw Exception('Failed to create account: $e');
}
}
}
}

View File

@@ -5,7 +5,13 @@ import 'package:citycards_customer/common_packages/custom_textfield.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../core/route_constants.dart';
import 'package:citycards_customer/my_pass/blocs/myPasses/my_passes_event.dart';
import '../../itinerary_creation/bloc/get_itinerary_bloc.dart';
import '../../localPreference/local_preference.dart';
import '../../my_pass/blocs/myPasses/my_passes_bloc.dart';
import '../../postcard/blocs/myPostCards/my_postcard_bloc.dart';
import '../../postcard/blocs/myPostCards/my_postcard_event.dart';
import '../../profile/bloc/profile/profile_bloc.dart';
import '../../profile/bloc/profile/profile_event.dart';
import '../bloc/create_account_bloc.dart';
@@ -13,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<CreateAccountView> createState() => _CreateAccountViewState();
}
class _CreateAccountViewState extends State<CreateAccountView> {
final TextEditingController firstNameController = TextEditingController();
final TextEditingController lastNameController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
final TextEditingController addressController = TextEditingController();
final TextEditingController cityController = TextEditingController();
final TextEditingController postalController = TextEditingController();
String? selectedState;
String? selectedCountry;
void _submitForm(BuildContext context) {
if (firstNameController.text.trim().isEmpty ||
lastNameController.text.trim().isEmpty ||
emailController.text.trim().isEmpty ||
phoneController.text.trim().isEmpty ||
addressController.text.trim().isEmpty) {
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<CreateAccountBloc, CreateAccountState>(
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<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
context.read<MyPostCardBloc>().add(CheckLoginStatus());
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
// context.read<MyPostCardBloc>().add(FetchDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.message)));
} else if (state is CreateAccountFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -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<String>(
value: selectedState,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select state",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedState = value;
});
},
items: [
"New South Wales",
"Victoria",
"Queensland",
"South Australia",
"Western Australia",
"Tasmania",
"Northern Territory",
"Australian Capital Territory"
].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
// Country Dropdown
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Country", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountry,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select country",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedCountry = value;
});
},
items: ["Australia"].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Postal Code",
hint: "Enter postal / zip code",
controller: postalController,
keyboardType: TextInputType.number,
),
),
SizedBox(height: 20.h),
BlocBuilder<CreateAccountBloc, CreateAccountState>(
builder: (context, state) {
if (state is CreateAccountLoading) {
@@ -206,4 +390,4 @@ class CreateAccountView extends StatelessWidget {
),
);
}
}
}

View File

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

View File

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

View File

@@ -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<GetItineraryEvent, GetItineraryState> {
// final ItineraryRepository _repository;
//
// GetItineraryBloc({ItineraryRepository? repository})
// : _repository = repository ?? ItineraryRepository(),
// super(GetItineraryInitial()) {
// on<CheckLoginAndFetchItinerary>(_onCheckLoginAndFetch);
// on<GetIiterary>(_onGetItinerary);
// }
//
// Future<void> _onCheckLoginAndFetch(
// CheckLoginAndFetchItinerary event,
// Emitter<GetItineraryState> emit,
// ) async {
// try {
// emit(GetItineraryLoading());
//
// final isLoggedIn = await LocalPreference.getLogin();
//
// if (!isLoggedIn) {
// emit(GetItineraryNotLoggedIn());
// return;
// }
//
// final response = await _repository.fetchMyItineraries();
//
// // Check if user has unlimited pass
// if (!response.isUnlimitedPass) {
// emit(GetItineraryRequiresPass(itineraries: response.itineraries));
// return;
// }
//
// emit(GetItinerarySuccessfully(itineraries: response.itineraries));
// } catch (e) {
// emit(GetItineraryFailed(
// error: e.toString().contains('Exception')
// ? e.toString().replaceAll('Exception: ', '')
// : "Failed to load itineraries. Please try again."));
// }
// }
//
// Future<void> _onGetItinerary(
// GetIiterary event,
// Emitter<GetItineraryState> emit,
// ) async {
// try {
// emit(GetItineraryLoading());
//
// final response = await _repository.fetchMyItineraries();
//
// // Check if user has unlimited pass
// if (!response.isUnlimitedPass) {
// emit(GetItineraryRequiresPass(itineraries: response.itineraries));
// return;
// }
//
// emit(GetItinerarySuccessfully(itineraries: response.itineraries));
// } catch (e) {
// emit(GetItineraryFailed(
// error: e.toString().contains('Exception')
// ? e.toString().replaceAll('Exception: ', '')
// : "Failed to load itineraries. Please try again."));
// }
// }
// }
import 'package:bloc/bloc.dart';
import 'package:citycards_customer/itinerary_creation/repository/itinerary_repository.dart';
import 'package:citycards_customer/itinerary_creation/models/my_itinerary_model.dart';
import 'package:citycards_customer/localPreference/local_preference.dart';
import 'package:equatable/equatable.dart';
part 'get_itinerary_event.dart';
part 'get_itinerary_state.dart';
class GetItineraryBloc extends Bloc<GetItineraryEvent, GetItineraryState> {
final ItineraryRepository _repository;
GetItineraryBloc({ItineraryRepository? repository})
: _repository = repository ?? ItineraryRepository(),
super(GetItineraryInitial()) {
on<CheckLoginAndFetchItinerary>(_onCheckLoginAndFetch);
on<GetIiterary>(_onGetItinerary);
}
Future<void> _onCheckLoginAndFetch(
CheckLoginAndFetchItinerary event,
Emitter<GetItineraryState> emit,
) async {
try {
emit(GetItineraryLoading());
final isLoggedIn = await LocalPreference.getLogin();
if (!isLoggedIn) {
emit(GetItineraryNotLoggedIn());
return;
}
final response = await _repository.fetchMyItineraries();
// 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<void> _onGetItinerary(
GetIiterary event,
Emitter<GetItineraryState> 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,
),
],
),
],
);
}
}

View File

@@ -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<GetItineraryCitiesEvent, GetItineraryCitiesState> {
GetItineraryCitiesBloc() : super(GetItineraryCitiesInitial()) {
on<GetItineraryCities>((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"));
}
});
}
}

View File

@@ -0,0 +1,10 @@
part of 'get_itinerary_cities_bloc.dart';
abstract class GetItineraryCitiesEvent extends Equatable {
const GetItineraryCitiesEvent();
@override
List<Object> get props => [];
}
class GetItineraryCities extends GetItineraryCitiesEvent {}

View File

@@ -0,0 +1,22 @@
part of 'get_itinerary_cities_bloc.dart';
abstract class GetItineraryCitiesState extends Equatable {
const GetItineraryCitiesState();
@override
List<Object> get props => [];
}
class GetItineraryCitiesInitial extends GetItineraryCitiesState {}
class GetItineraryCitiesLoading extends GetItineraryCitiesState {}
class GetItineraryCitiesSuccessfully extends GetItineraryCitiesState {
final List<ItineraryCityModel> cities;
const GetItineraryCitiesSuccessfully({required this.cities});
}
class GetItineraryCitiesFailed extends GetItineraryCitiesState {
final String error;
const GetItineraryCitiesFailed({required this.error});
}

View File

@@ -0,0 +1,12 @@
part of 'get_itinerary_bloc.dart';
abstract class GetItineraryEvent extends Equatable {
const GetItineraryEvent();
@override
List<Object> get props => [];
}
class GetIiterary extends GetItineraryEvent {}
class CheckLoginAndFetchItinerary extends GetItineraryEvent {}

View File

@@ -0,0 +1,41 @@
part of 'get_itinerary_bloc.dart';
abstract class GetItineraryState extends Equatable {
const GetItineraryState();
@override
List<Object> get props => [];
}
final class GetItineraryInitial extends GetItineraryState {}
class GetItineraryLoading extends GetItineraryState {}
class GetItineraryNotLoggedIn extends GetItineraryState {}
class GetItinerarySuccessfully extends GetItineraryState {
final List<MyItinerary> itineraries;
const GetItinerarySuccessfully({required this.itineraries});
@override
List<Object> get props => [itineraries];
}
class GetItineraryRequiresPass extends GetItineraryState {
final List<MyItinerary> itineraries;
const GetItineraryRequiresPass({required this.itineraries});
@override
List<Object> get props => [itineraries];
}
class GetItineraryFailed extends GetItineraryState {
final String error;
const GetItineraryFailed({required this.error});
@override
List<Object> get props => [error];
}

View File

@@ -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<AddDateToItinerary>((event, emit) {
@@ -137,10 +141,13 @@ class AddItineraryDetailBloc
});
on<AddCityToItinerary>((event, emit) {
print("Selected city: ${event.city}");
emit(state.copyWith(selectedCity: event.city));
});
on<AddAddressToItinerary>((event, emit) {
emit(state.copyWith(baseAdd: event.address));
});
on<AddEnergyToItinerary>((event, emit) {
emit(state.copyWith(selectedEnergy: event.energy));
});
@@ -150,13 +157,6 @@ class AddItineraryDetailBloc
});
on<AddDietaryToItinerary>((event, emit) {
// final currentSelection = List<String>.from(state.selectedDietary ?? []);
//
// if (currentSelection.contains(event.dietary)) {
// currentSelection.remove(event.dietary);
// } else {
// currentSelection.add(event.dietary);
// }
emit(state.copyWith(selectedDietary: event.dietary));
});

View File

@@ -0,0 +1,6 @@
class CurrentLocationModel {
final String? baseAdd;
final double? lat;
final double? lan;
CurrentLocationModel({this.baseAdd, this.lan, this.lat});
}

View File

@@ -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<String, dynamic> json) {
id = json['id'];
cityName = json['cityName'];
urlSlug = json['urlSlug'];
iconXid = json['iconXid'];
icon = json['icon'] != null ? Icon.fromJson(json['icon']) : null;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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<String, dynamic> json) {
id = json['id'];
iconName = json['iconName'];
iconSvg = json['iconSvg'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['iconName'] = iconName;
data['iconSvg'] = iconSvg;
return data;
}
}

View File

@@ -0,0 +1,250 @@
class MyItineraryResponse {
bool isUnlimitedPass;
List<MyItinerary> itineraries;
MyItineraryResponse({
required this.isUnlimitedPass,
required this.itineraries,
});
factory MyItineraryResponse.fromJson(Map<String, dynamic>? json) {
json ??= {};
return MyItineraryResponse(
isUnlimitedPass: json['isUnlimitedPass'] ?? false,
itineraries: json['itineraries'] == null
? []
: List<Map<String, dynamic>>.from(json['itineraries'])
.map((e) => MyItinerary.fromJson(e))
.toList(),
);
}
Map<String, dynamic> 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<String> dietaryPreferences;
Preferences preferences;
int totalDays;
String aiModel;
String promptVersion;
bool isActive;
String createdAt;
String updatedAt;
List<ItineraryDay> 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<String, dynamic>? 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<String>.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<Map<String, dynamic>>.from(json['days'])
.map((e) => ItineraryDay.fromJson(e))
.toList(),
);
}
Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic> toJson() => {
"shopping": shopping,
"wildlife": wildlife,
"landmarks": landmarks,
"scenicViews": scenicViews,
"artAndMuseums": artAndMuseums,
};
}
class ItineraryDay {
int id;
int itineraryXid;
int dayNumber;
String title;
String summary;
List<DayItem> items;
ItineraryDay({
required this.id,
required this.itineraryXid,
required this.dayNumber,
required this.title,
required this.summary,
required this.items,
});
factory ItineraryDay.fromJson(Map<String, dynamic>? 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<Map<String, dynamic>>.from(json['items'])
.map((e) => DayItem.fromJson(e))
.toList(),
);
}
Map<String, dynamic> 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<String, dynamic>? 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<String, dynamic> toJson() => {
"id": id,
"itineraryDayXid": itineraryDayXid,
"timeSlot": timeSlot,
"title": title,
"description": description,
"locationName": locationName,
"imageUrl": imageUrl,
"latitude": latitude,
"longitude": longitude,
};
}

View File

@@ -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<MyItineraryResponse> 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<List<ItineraryCityModel>> fetchItineraryCities() async {
try {
final response = await _apiService.getApi(
url: ApiUrls.getItineraryCities,
);
final List<ItineraryCityModel> cities = (response.data as List)
.map((e) => ItineraryCityModel.fromJson(e as Map<String, dynamic>))
.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;
}
}
}

View File

@@ -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<CitySelectionView> createState() => _CitySelectionViewState();
}
List<MenuItem> 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<Map<String, String>> 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<CitySelectionView> {
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<MenuItem>(
controller: cityController,
initialSelection: selectedMenuItem,
width: double.infinity,
hintText: "Select City",
requestFocusOnTap: true,
enableFilter: true,
showTrailingIcon: false,
onSelected: (MenuItem? menu) {
context.read<AddItineraryDetailBloc>().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<DropdownMenuEntry<MenuItem>>((
MenuItem menu,
) {
return DropdownMenuEntry<MenuItem>(
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<AddItineraryDetailBloc, ItineraryDetailState>(
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<AddItineraryDetailBloc>().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<GetItineraryCitiesBloc, GetItineraryCitiesState>(
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<AddItineraryDetailBloc, ItineraryDetailState>(
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<AddItineraryDetailBloc>().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(

View File

@@ -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<CurrentLocationSelection> {
final TextEditingController _controller = TextEditingController();
LatLng? _currentLatLng;
bool loading = false;
Future<void> _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<void> _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<CurrentLocationSelection> {
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<CurrentLocationSelection> {
// --- Continue button ---
CustomFilledButton(
onTap: () {
context.read<AddItineraryDetailBloc>().add(
AddAddressToItinerary(
CurrentLocationModel(
baseAdd: _controller.text,
lan: _currentLatLng?.latitude,
lat: _currentLatLng?.latitude,
),
),
);
context.read<ItineraryStepNavigationBloc>().add(
ItineraryStepNavigationNextEvent(),
);

View File

@@ -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<AddItineraryDetailBloc, ItineraryDetailState>(
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<AddItineraryDetailBloc, ItineraryDetailState>(
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),

View File

@@ -69,7 +69,7 @@ class ItineraryCompletionView extends StatelessWidget {
),
_buildProfileRow(
"City",
state.selectedCity ?? "",
state.selectedCity!.cityName ?? "",
),
_buildProfileRow(
"Energy",

View File

@@ -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<ItineraryCreationPage> {
children: [
DateSelectionView(),
CurrentLocationSelection(),
CitySelectionView(),
BlocProvider(
create: (context) => GetItineraryCitiesBloc(),
child: CitySelectionView(),
),
EnergySelectionView(),
KidsSelectionView(),
DietarySelectionView(),

View File

@@ -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<MagicItineraryView> {
bool isLoggedIn = false;
bool isLoading = true;
@override
void initState() {
super.initState();
_checkLoginStatus();
}
Future<void> _checkLoginStatus() async {
// final loginStatus = await LocalPreference.getLogin();
final loginStatus = true;
setState(() {
isLoggedIn = loginStatus;
isLoading = false;
});
// Trigger login check and fetch on init
context.read<GetItineraryBloc>().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<MagicItineraryView> {
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<GetItineraryBloc, GetItineraryState>(
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<GetItineraryBloc>()
.add(CheckLoginAndFetchItinerary());
},
);
}
// Initial state
return SizedBox.shrink();
},
),
],
),
),
@@ -105,8 +135,8 @@ class _MagicItineraryViewState extends State<MagicItineraryView> {
}
}
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<NavigationBloc>().add(NavigationTabChanged(0));
},
label: "Buy Unlimited CityCard",
showArrow: true,
),
],
);
}
}
class NoItineraryView extends StatelessWidget {
const NoItineraryView({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Column(
children: [
SizedBox(height: 60.h),
/// Illustration Image
Center(
child: Image.asset(
"assets/images/no_itinerary.png",
height: 260.h,
fit: BoxFit.contain,
),
),
SizedBox(height: 32.h),
/// Title
CustomText(
text: "You Dont have an Itinerary Yet! 😟",
size: 18.sp,
weight: FontWeight.w600,
textAlign: TextAlign.center,
),
SizedBox(height: 12.h),
/// Subtitle
CustomText(
text:
"Create your own personalized magic itinerary that suites your travel needs",
size: 14.sp,
color: const Color(0xFF656565),
textAlign: TextAlign.center,
),
SizedBox(height: 32.h),
/// Button
CustomFilledButton(
onTap: () {
Navigator.push(
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,

View File

@@ -22,14 +22,6 @@ class LocalDatabase {
path,
version: 1,
onCreate: (db, version) async {
/// CITY TABLE
await db.execute('''
CREATE TABLE selected_city (
id INTEGER PRIMARY KEY,
city_id INTEGER
)
''');
/// ONBOARDING TABLE
await db.execute('''
CREATE TABLE onboarding_state (
@@ -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
)
''');
},
);
}

View File

@@ -395,6 +395,32 @@ class LocalPreference {
}
}
static Future<void> setSelectedCityLogo(String logoUrl) async {
final db = await LocalDatabase().database;
await db.update(
'selected_city',
{'city_logo': logoUrl},
where: 'id = ?',
whereArgs: [1],
);
}
static Future<String?> 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<void> clearUserDetails() async {
final db = await LocalDatabase().database;

View File

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

View File

@@ -1,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<VerifyOtpBottomsheet> {
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
context.read<MyPostCardBloc>().add(CheckLoginStatus());
context.read<MyPostCardBloc>().add(FetchDraftPostCards());
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
// context.read<MyPostCardBloc>().add(FetchDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
context.read<MyPostCardBloc>().add(FetchOrderPostCards());
// context.read<MyPostCardBloc>().add(FetchOrderPostCards());
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
// User exists - navigate to home/dashboard
// Navigator.of(context).pushReplacementNamed(RouteConstants.home);
ScaffoldMessenger.of(context).showSnackBar(
@@ -56,7 +61,7 @@ class _VerifyOtpBottomsheetState extends State<VerifyOtpBottomsheet> {
);
} else {
// User doesn't exist - navigate to create account
Navigator.of(context).pushReplacementNamed(RouteConstants.createAcct,arguments: widget.emailAddress);
Navigator.of(context).pushNamed(RouteConstants.createAcct,arguments: widget.emailAddress);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please complete your profile'),
@@ -72,6 +77,7 @@ class _VerifyOtpBottomsheetState extends State<VerifyOtpBottomsheet> {
),
);
} else if (state is VerifyOtpError) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage),

View File

@@ -1,4 +1,5 @@
import 'package:citycards_customer/cart/blocs/postcard_bloc.dart';
import 'package:citycards_customer/cart/repository/my_pass_cart_repository.dart';
import 'package:citycards_customer/core/route_constants.dart';
import 'package:citycards_customer/search_offers/bloc/offers_bloc.dart';
import 'package:citycards_customer/trail.dart';
@@ -8,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<MyPassBloc>(
create: (_) => MyPassBloc()..add(LoadMyPasses()),
),
BlocProvider<MyPassesBloc>(
create: (_) => MyPassesBloc(MyPassesRepository()),
),
BlocProvider<MyPassCartBloc>(
create: (_) => MyPassCartBloc(repository: MyPassCartRepository()),
),
BlocProvider<FirstTimeUserHomeBloc>(
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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,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<MyPassBloc, MyPassState>(
builder: (context, state) {
if (state is MyPassLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is MyPassEmpty) {
return _noPassView(context);
} else if (state is MyPassLoaded) {
return _passListView(state.passes);
}
return const SizedBox.shrink();
},
State<MyPassesView> createState() => _MyPassesViewState();
}
class _MyPassesViewState extends State<MyPassesView> {
String selectedCardMode = "";
String selectedSort = "";
@override
void initState() {
super.initState();
// Changed from FetchMyPasses to CheckLoginAndFetchPasses
context.read<MyPassesBloc>().add(const CheckLoginAndFetchPasses());
}
void _showCardModeBottomSheet() {
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
),
builder: (context) {
return Container(
padding: EdgeInsets.all(16.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(
"All",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedCardMode = "";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: "",
sort: selectedSort,
));
},
),
ListTile(
title: Text(
"flexi",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedCardMode = "flexi";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: "flexi",
sort: selectedSort,
));
},
),
ListTile(
title: Text(
"unlimited",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedCardMode = "unlimited";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: "unlimited",
sort: selectedSort,
));
},
),
],
),
);
},
);
}
Widget _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 Dont have a Pass Yet! 😕",
style: GoogleFonts.poppins(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
Text(
"Get a pass and get offers and discounts and\nmore on your trip to your favourite city",
style: GoogleFonts.poppins(fontSize: 12.sp, color: Colors.black54),
textAlign: TextAlign.center,
),
SizedBox(height: 24.h),
GestureDetector(
onTap: () {
// Navigate to Buy a Pass
Navigator.pushNamed(context, '/buyPass');
},
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<MyPassesBloc>().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<MyPassesBloc>().add(FetchMyPasses(
cardMode: selectedCardMode,
sort: "latest",
));
},
),
ListTile(
title: Text(
"oldest",
style: GoogleFonts.poppins(fontSize: 14.sp),
),
onTap: () {
setState(() {
selectedSort = "oldest";
});
Navigator.pop(context);
context.read<MyPassesBloc>().add(FetchMyPasses(
cardMode: selectedCardMode,
sort: "oldest",
));
},
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: BlocBuilder<MyPassesBloc, MyPassesState>(
builder: (context, state) {
if (state is MyPassesLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is MyPassesNotLoggedIn) {
// New state handling for not logged in users
return _notLoggedInView(context);
} else if (state is MyPassesLoaded) {
return SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
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<MyPassBloc>().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<NavigationBloc>().add(NavigationTabChanged(0));
},
label: "Buy a Pass",
showArrow: true,
),
SizedBox(height: 40.h),
],
),
);
}
}
Widget _passListView(List passes) {
return ListView.builder(
itemCount: passes.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
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),
),
);
},
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,409 @@
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/custom_search_field.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:citycards_customer/core/route_constants.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../common_packages/common_app_texts.dart';
import '../../networkApiServices/api_urls.dart';
import '../blocs/myPassesOffers/my_passes_offers_bloc.dart';
import '../blocs/myPassesOffers/my_passes_offers_event.dart';
import '../blocs/myPassesOffers/my_passes_offers_state.dart';
import '../repository/my_passes_offers_repository.dart';
class PassOffersScreen extends StatefulWidget {
final int cityId;
const PassOffersScreen({
super.key,
required this.cityId,
});
@override
State<PassOffersScreen> createState() => _PassOffersScreenState();
}
class _PassOffersScreenState extends State<PassOffersScreen> {
int? selectedCategoryId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository())
..add(LoadMyPassesOffers(cityXid: widget.cityId)),
child: Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showCart: false,
showDivider: true,
),
Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Icon(Icons.arrow_back),
),
SizedBox(width: 8.w),
CustomText(
text: "Offers with ${CommonAppText.selectiveCard} Card",
size: 12.sp,
),
],
),
SizedBox(height: 33.h),
Builder(
builder: (context) => CommonSearchField(
hint: "Search offers",
hintColor: const Color(0xFFF95F62).withOpacity(.6),
showSuffix: true,
onChanged: (value) {
context.read<MyPassesOffersBloc>().add(SearchMyPassesOffers(value));
},
),
),
SizedBox(height: 20.h),
/// Dynamic Categories
BlocBuilder<MyPassesOffersBloc, MyPassesOffersState>(
builder: (context, state) {
if (state is MyPassesOffersLoaded) {
final categories = state.categories;
if (categories.isEmpty) {
return SizedBox.shrink();
}
return Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
...List.generate(categories.length, (index) {
final category = categories[index];
final isSelected =
selectedCategoryId == category.id;
return Padding(
padding: EdgeInsets.only(right: 8.0.w),
child: GestureDetector(
onTap: () {
setState(() {
if (selectedCategoryId == category.id) {
// Deselect if already selected
selectedCategoryId = null;
context
.read<MyPassesOffersBloc>()
.add(LoadMyPassesOffers(cityXid: widget.cityId));
} else {
// Select new category
selectedCategoryId = category.id;
context.read<MyPassesOffersBloc>().add(
LoadMyPassesOffers(
cityXid: widget.cityId,
categoryXid: category.id,
),
);
}
});
},
child: Container(
padding: EdgeInsets.symmetric(
vertical: 8.h,
horizontal: 12.w,
),
decoration: BoxDecoration(
color: isSelected
? Color(0xFFF95F62)
: Color(0xFFFEE7E7),
borderRadius:
BorderRadius.circular(100.sp),
border: Border.all(
color: isSelected
? Color(0xFFF95F62)
: Color(0xFFFDCDCE),
),
),
child: Center(
child: CustomText(
text: category.categoryName,
color: isSelected
? Colors.white
: Color(0xFFF95F62),
),
),
),
),
);
}),
],
),
),
);
}
return SizedBox.shrink();
},
),
SizedBox(height: 20.h),
/// Offer list
Expanded(
child: BlocBuilder<MyPassesOffersBloc, MyPassesOffersState>(
builder: (context, state) {
if (state is MyPassesOffersLoading) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFFF95F62),
),
);
}
if (state is MyPassesOffersError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48.sp,
color: Colors.red,
),
SizedBox(height: 16.h),
Text(
state.message,
style: TextStyle(
color: Colors.red,
fontSize: 14.sp,
),
textAlign: TextAlign.center,
),
],
),
);
}
if (state is MyPassesOffersLoaded) {
final offers = state.offers;
if (offers.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.local_offer_outlined,
size: 48.sp,
color: Colors.grey,
),
SizedBox(height: 16.h),
Text(
"No offers found",
style: TextStyle(
color: Colors.grey,
fontSize: 14.sp,
),
),
],
),
);
}
return GridView.builder(
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.w,
mainAxisSpacing: 22.h,
childAspectRatio: 0.65,
),
itemCount: offers.length,
itemBuilder: (context, index) {
final offer = offers[index];
return InkWell(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.offerPassDetail,
arguments: offer.id, // ✅ pass offerId
);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 6.w,
vertical: 6.h,
),
decoration: BoxDecoration(
border: Border.all(
color:
Color(0xFFF95F62).withOpacity(.24),
),
borderRadius: BorderRadius.circular(12.sp),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius:
BorderRadius.circular(8.sp),
child: offer.mobileBannerImage != null &&
offer.mobileBannerImage!
.isNotEmpty
? Image.network(
'${ApiUrls.baseUrl}/${offer.mobileBannerImage}',
width: double.infinity,
height: 120.5.h,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) {
return Container(
width: double.infinity,
height: 120.5.h,
color: Color(0xFFFEE7E7),
child: Icon(
Icons.local_offer,
size: 40.sp,
color: Color(0xFFF95F62)
.withOpacity(.6),
),
);
},
loadingBuilder: (context, child,
loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Container(
width: double.infinity,
height: 120.5.h,
color: Color(0xFFFEE7E7),
child: Center(
child:
CircularProgressIndicator(
value: loadingProgress
.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
strokeWidth: 2,
color:
Color(0xFFF95F62),
),
),
);
},
)
: Container(
width: double.infinity,
height: 120.5.h,
color: Color(0xFFFEE7E7),
child: Icon(
Icons.local_offer,
size: 40.sp,
color: Color(0xFFF95F62)
.withOpacity(.6),
),
),
),
SizedBox(height: 8.h),
CustomText(
text: offer.title,
size: 18.sp,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 8.h),
Expanded(
child: CustomText(
text: offer.description,
color: Colors.black.withOpacity(.6),
size: 12.sp,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (offer.offerCode != null && offer.offerCode!.isNotEmpty) ...[
SizedBox(height: 8.h),
GestureDetector(
onTap: () {
Clipboard.setData(
ClipboardData(text: offer.offerCode!),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Code copied: ${offer.offerCode!}"),
duration: Duration(seconds: 1),
backgroundColor: Color(0xFFF95F62),
),
);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w,
vertical: 6.h,
),
decoration: BoxDecoration(
color: Color(0xFFFEE7E7),
borderRadius: BorderRadius.circular(6.sp),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: CustomText(
text: offer.offerCode!,
size: 12.sp,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.copy,
size: 16.sp,
color: Color(0xFFF95F62),
),
],
),
),
),
],
],
),
),
);
},
);
}
return const Center(
child: Text(
"No data available",
style: TextStyle(color: Colors.grey, fontSize: 14),
),
);
},
),
),
],
),
),
),
),
);
}
}

View File

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

View File

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

View File

@@ -5,21 +5,30 @@ class ApiUrls {
static const baseUrl = "https://uatapi.citycard.betadelivery.com";// Production Lvl API
static const refreshToken = "$baseUrl/auth/refresh";
static const cityList = "$baseUrl/mobile/city_list";
// 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 +37,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";
}
}

View File

@@ -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<Response> deleteApi({
required String url,
dynamic data,
Map<String, dynamic>? 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<bool> _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;

View File

@@ -24,7 +24,7 @@ class OffersDetailsView extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => OfferDetailsBloc(
repository: OffersDetailsRepository(), // Create directly
repository: OffersDetailsRepository(), // Create directly
)..add(FetchOfferDetailsEvent(offerId: offerId)),
child: const _OffersDetailsContent(),
);
@@ -106,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 {
),
);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<EditPostcardEvent, EditPostcardState> {
EditPostcardBloc() : super(EditPostcardInitial()) {
on<EditPostCard>((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"));
}
});
}
}

View File

@@ -0,0 +1,13 @@
part of 'edit_postcard_bloc.dart';
class EditPostcardEvent extends Equatable {
const EditPostcardEvent();
@override
List<Object> get props => [];
}
class EditPostCard extends EditPostcardEvent {
final MyPostCard myPostCard;
const EditPostCard({required this.myPostCard});
}

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