Merge remote-tracking branch 'origin/raj' into Anuj
BIN
assets/icons/calendar.png
Normal file
|
After Width: | Height: | Size: 863 B |
BIN
assets/icons/person.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/icons/time.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
assets/images/no_itinerary.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 749 KiB After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/unlimited_card_details.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
207
lib/cart/model/my_passes_cart_mode.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class CommonAppText {
|
||||
static const String selectiveCard = "Selective";
|
||||
static const String selectiveCard = "Flexi";
|
||||
}
|
||||
49
lib/common_packages/custom_dash_border_painter.dart
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
6
lib/core/global_keys.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GlobalKeys {
|
||||
static final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
240
lib/itinerary_creation/bloc/get_itinerary_bloc.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/itinerary_creation/bloc/get_itinerary_cities_bloc.dart
Normal 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"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
10
lib/itinerary_creation/bloc/get_itinerary_cities_event.dart
Normal 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 {}
|
||||
22
lib/itinerary_creation/bloc/get_itinerary_cities_state.dart
Normal 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});
|
||||
}
|
||||
12
lib/itinerary_creation/bloc/get_itinerary_event.dart
Normal 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 {}
|
||||
41
lib/itinerary_creation/bloc/get_itinerary_state.dart
Normal 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];
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class CurrentLocationModel {
|
||||
final String? baseAdd;
|
||||
final double? lat;
|
||||
final double? lan;
|
||||
CurrentLocationModel({this.baseAdd, this.lan, this.lat});
|
||||
}
|
||||
57
lib/itinerary_creation/models/itinerary_city_model.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
250
lib/itinerary_creation/models/my_itinerary_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
42
lib/itinerary_creation/repository/itinerary_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -69,7 +69,7 @@ class ItineraryCompletionView extends StatelessWidget {
|
||||
),
|
||||
_buildProfileRow(
|
||||
"City",
|
||||
state.selectedCity ?? "",
|
||||
state.selectedCity!.cityName ?? "",
|
||||
),
|
||||
_buildProfileRow(
|
||||
"Energy",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 Don’t have an Itinerary Yet! 😟",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w600,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
/// Subtitle
|
||||
CustomText(
|
||||
text:
|
||||
"Create your own personalized magic itinerary that suites your travel needs",
|
||||
size: 14.sp,
|
||||
color: const Color(0xFF656565),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
SizedBox(height: 32.h),
|
||||
|
||||
/// Button
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ItineraryCreationStartPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
label: "Create My Itinerary",
|
||||
showArrow: true,
|
||||
),
|
||||
|
||||
SizedBox(height: 40.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorItineraryView extends StatelessWidget {
|
||||
final String error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const ErrorItineraryView({
|
||||
super.key,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 40.h),
|
||||
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 120.sp,
|
||||
color: Colors.red.withOpacity(0.3),
|
||||
),
|
||||
|
||||
SizedBox(height: 32.h),
|
||||
|
||||
CustomText(
|
||||
text: "Oops! Something went wrong",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w600,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
child: CustomText(
|
||||
text: error,
|
||||
size: 14.sp,
|
||||
color: Color(0xFF656565),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 32.h),
|
||||
|
||||
CustomFilledButton(
|
||||
onTap: onRetry,
|
||||
label: "Try Again",
|
||||
showArrow: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ItineraryFilledCard extends StatelessWidget {
|
||||
final MyItinerary itinerary;
|
||||
|
||||
const ItineraryFilledCard({
|
||||
super.key,
|
||||
required this.itinerary,
|
||||
});
|
||||
|
||||
String _formatDate(String dateString) {
|
||||
try {
|
||||
final date = DateTime.parse(dateString);
|
||||
return DateFormat('M/d/yyyy').format(date);
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
int _getTotalAttractions() {
|
||||
int total = 0;
|
||||
for (var day in itinerary.days) {
|
||||
total += day.items.length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
String _getCityName() {
|
||||
// You might want to fetch city name from cityXid or use address
|
||||
// For now, extracting from address
|
||||
if (itinerary.address.isNotEmpty) {
|
||||
return itinerary.address.split(',').last.trim();
|
||||
}
|
||||
return "Unknown City";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalAttractions = _getTotalAttractions();
|
||||
final cityName = _getCityName();
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h),
|
||||
decoration: BoxDecoration(
|
||||
@@ -183,19 +415,23 @@ class ItineraryFilledCard extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Melbourne Unlimited Card",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
Expanded(
|
||||
child: CustomText(
|
||||
text: "$cityName Travel Plan",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 2.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF439F6E),
|
||||
color: itinerary.isActive
|
||||
? Color(0xFF439F6E)
|
||||
: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(100.r),
|
||||
),
|
||||
child: CustomText(
|
||||
text: "Active",
|
||||
text: itinerary.isActive ? "Active" : "Inactive",
|
||||
size: 11.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
@@ -204,7 +440,7 @@ class ItineraryFilledCard extends StatelessWidget {
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
CustomText(
|
||||
text: "Melbourne",
|
||||
text: cityName,
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
),
|
||||
@@ -213,7 +449,11 @@ class ItineraryFilledCard extends StatelessWidget {
|
||||
children: [
|
||||
Image.asset("assets/icons/calender_filled.png", width: 16.sp),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(text: "7 days", color: Color(0xFF8E8E8E), size: 12.sp),
|
||||
CustomText(
|
||||
text: "${itinerary.totalDays} days",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
@@ -226,7 +466,7 @@ class ItineraryFilledCard extends StatelessWidget {
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text: "6 attractions",
|
||||
text: "$totalAttractions attractions",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
@@ -238,17 +478,34 @@ class ItineraryFilledCard extends StatelessWidget {
|
||||
Icon(Icons.watch_later, color: Color(0xFF8E8E8E), size: 16.sp),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text: "Created 1/15/2024",
|
||||
text: "Created ${_formatDate(itinerary.createdAt)}",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
// if (itinerary.travelingWithKids) ...[
|
||||
// SizedBox(height: 8.h),
|
||||
// Row(
|
||||
// children: [
|
||||
// Icon(Icons.family_restroom,
|
||||
// color: Color(0xFF8E8E8E), size: 16.sp),
|
||||
// SizedBox(width: 4.w),
|
||||
// CustomText(
|
||||
// text: "Family Friendly",
|
||||
// color: Color(0xFF8E8E8E),
|
||||
// size: 12.sp,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ],
|
||||
SizedBox(height: 12.h),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context)
|
||||
.pushReplacementNamed(RouteConstants.yourItinerary);
|
||||
Navigator.of(context).pushReplacementNamed(
|
||||
RouteConstants.yourItinerary,
|
||||
arguments: itinerary,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 43.h,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
''');
|
||||
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
85
lib/my_pass/blocs/myPasses/my_passes_bloc.dart
Normal 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."));
|
||||
}
|
||||
}
|
||||
}
|
||||
50
lib/my_pass/blocs/myPasses/my_passes_event.dart
Normal 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];
|
||||
}
|
||||
39
lib/my_pass/blocs/myPasses/my_passes_state.dart
Normal 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];
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
67
lib/my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
lib/my_pass/blocs/myPassesOffers/my_passes_offers_event.dart
Normal 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);
|
||||
}
|
||||
22
lib/my_pass/blocs/myPassesOffers/my_passes_offers_state.dart
Normal 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);
|
||||
}
|
||||
167
lib/my_pass/models/my_passes_details_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
119
lib/my_pass/models/my_passes_model.dart
Normal 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 ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
29
lib/my_pass/repository/my_passes_attractions_repository.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/my_pass/repository/my_passes_details_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
25
lib/my_pass/repository/my_passes_offers_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
32
lib/my_pass/repository/my_passes_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 Don’t have a Pass Yet! 😕",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
"Get a pass and get offers and discounts and\nmore on your trip to your favourite city",
|
||||
style: GoogleFonts.poppins(fontSize: 12.sp, color: Colors.black54),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// Navigate to Buy a Pass
|
||||
Navigator.pushNamed(context, '/buyPass');
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffFF5A5F),
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
void _showSortBottomSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
|
||||
),
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(
|
||||
"All",
|
||||
style: GoogleFonts.poppins(fontSize: 14.sp),
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedSort = "";
|
||||
});
|
||||
Navigator.pop(context);
|
||||
context.read<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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
727
lib/my_pass/views/pass_attraction_details_view.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/my_pass/views/pass_attractions_page_view.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
655
lib/my_pass/views/pass_details_page_view.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
409
lib/my_pass/views/search_pass_offers_with_listing.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
203
lib/my_pass/widgets/pass_attraction_card.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,26 @@ class ApiUrls {
|
||||
// static const upcomingCityList = "$baseUrl/mobile/upcoming_cities";
|
||||
static const searchCityList = "$baseUrl/mobile/city-selection";
|
||||
static const attractionsList = "$baseUrl/mobile/list/all";
|
||||
static const passAttractionsList = "$baseUrl/mobile/passes/mobile/list";
|
||||
static const attractionDetails = "$baseUrl/mobile/list";
|
||||
static const home = "$baseUrl/mobile";
|
||||
static const faqPrivacyTerms = "$baseUrl/mobile/user/cms-data";
|
||||
static const userProfile = "$baseUrl/mobile/user";
|
||||
static const offers = "$baseUrl/mobile/list/offers";
|
||||
static const passOffers = "$baseUrl/mobile/passes/mobile/list/offers";
|
||||
static const buyAPass = "$baseUrl/mobile/pass";
|
||||
static const offersDetails = "$baseUrl/mobile/list/offers";
|
||||
static const myPostCards = "$baseUrl/mobile/postcards/all";
|
||||
static const coupons = "$baseUrl/mobile/passes/dropdown/card";
|
||||
static const myPasses = "$baseUrl/mobile/passes/all";
|
||||
static const passDetails = "$baseUrl/mobile/passes";
|
||||
static const myPassesCart = "$baseUrl/mobile/passes/cart/passes";
|
||||
|
||||
static const editPostcard = "$baseUrl/mobile/postcards";
|
||||
|
||||
static const myItineraries = "$baseUrl/mobile/itinerary/all-initineraries";
|
||||
static const getItineraryCities =
|
||||
"$baseUrl/mobile/itinerary/cities-with-icons";
|
||||
|
||||
//Post Apis
|
||||
static const createAccount = "$baseUrl/mobile/user/register";
|
||||
@@ -28,4 +38,4 @@ class ApiUrls {
|
||||
static const submitTicket = "$baseUrl/mobile/user/support";
|
||||
static const createPostCard = "$baseUrl/mobile/postcards";
|
||||
static const addToCartPasses = "$baseUrl/mobile/passes/add-to-cart";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
26
lib/postcard/blocs/edit_postcard/edit_postcard_bloc.dart
Normal 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"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
13
lib/postcard/blocs/edit_postcard/edit_postcard_event.dart
Normal 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});
|
||||
}
|
||||
19
lib/postcard/blocs/edit_postcard/edit_postcard_state.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
part of 'edit_postcard_bloc.dart';
|
||||
|
||||
class EditPostcardState extends Equatable {
|
||||
const EditPostcardState();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class EditPostcardInitial extends EditPostcardState {}
|
||||
|
||||
class EditPostcardLoading extends EditPostcardState {}
|
||||
|
||||
class EditPostcardSuccessfull extends EditPostcardState {}
|
||||
|
||||
class EditPostcardError extends EditPostcardState {
|
||||
final String error;
|
||||
const EditPostcardError({required this.error});
|
||||
}
|
||||