Compare commits
12 Commits
8f7a68edbc
...
aeeb1c27e0
| Author | SHA1 | Date | |
|---|---|---|---|
| aeeb1c27e0 | |||
| 46906b04f4 | |||
|
|
48fd7037ea | ||
|
|
40f0ed3a52 | ||
|
|
b08e2699e9 | ||
|
|
53264619a8 | ||
|
|
5d08e07de3 | ||
|
|
68c3f28d76 | ||
|
|
3a08830cce | ||
|
|
0c663bdec7 | ||
|
|
e91d24becc | ||
|
|
09726eb4e6 |
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 |
@@ -7,6 +7,8 @@ PODS:
|
|||||||
- flutter_native_splash (2.4.3):
|
- flutter_native_splash (2.4.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterAngle (0.0.8)
|
- FlutterAngle (0.0.8)
|
||||||
|
- geocoding_ios (1.0.5):
|
||||||
|
- Flutter
|
||||||
- geolocator_apple (1.2.0):
|
- geolocator_apple (1.2.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -97,6 +99,7 @@ DEPENDENCIES:
|
|||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_angle (from `.symlinks/plugins/flutter_angle/darwin`)
|
- flutter_angle (from `.symlinks/plugins/flutter_angle/darwin`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
|
- geocoding_ios (from `.symlinks/plugins/geocoding_ios/ios`)
|
||||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`)
|
- google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
@@ -129,6 +132,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_angle/darwin"
|
:path: ".symlinks/plugins/flutter_angle/darwin"
|
||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
|
geocoding_ios:
|
||||||
|
:path: ".symlinks/plugins/geocoding_ios/ios"
|
||||||
geolocator_apple:
|
geolocator_apple:
|
||||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||||
google_maps_flutter_ios:
|
google_maps_flutter_ios:
|
||||||
@@ -155,6 +160,7 @@ SPEC CHECKSUMS:
|
|||||||
flutter_angle: fc44e198cea1f07e1a5919bad1484049fab65c96
|
flutter_angle: fc44e198cea1f07e1a5919bad1484049fab65c96
|
||||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||||
FlutterAngle: c810891af800750361b1d0e7cc944f2338d5ae18
|
FlutterAngle: c810891af800750361b1d0e7cc944f2338d5ae18
|
||||||
|
geocoding_ios: eafacae6ad11a1eb56681f7d11df602a5fd49416
|
||||||
geolocator_apple: 66b711889fd333205763b83c9dcf0a57a28c7afd
|
geolocator_apple: 66b711889fd333205763b83c9dcf0a57a28c7afd
|
||||||
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
|
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
|
||||||
google_maps_flutter_ios: e31555a04d1986ab130f2b9f24b6cdc861acc6d3
|
google_maps_flutter_ios: e31555a04d1986ab130f2b9f24b6cdc861acc6d3
|
||||||
|
|||||||
@@ -9,21 +9,34 @@ import 'stripe_payment_state.dart';
|
|||||||
class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
|
class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
|
||||||
final StripeService _stripeService;
|
final StripeService _stripeService;
|
||||||
|
|
||||||
|
// 🔒 Flag to prevent re-initialization after success
|
||||||
|
bool _paymentCompleted = false;
|
||||||
|
|
||||||
StripePaymentBloc({
|
StripePaymentBloc({
|
||||||
StripeService? stripeService,
|
StripeService? stripeService,
|
||||||
}) : _stripeService = stripeService ?? StripeService(),
|
}) : _stripeService = stripeService ?? StripeService(),
|
||||||
super(const StripePaymentInitial()) {
|
super(const StripePaymentInitial()) {
|
||||||
on<InitiatePayment>(_onInitiatePayment);
|
on<InitiatePayment>(_onInitiatePayment);
|
||||||
on<InitiatePaymentWithClientSecret>(_onInitiatePaymentWithClientSecret);
|
on<InitiatePaymentWithClientSecret>(_onInitiatePaymentWithClientSecret);
|
||||||
|
on<CancelPaymentEvent>(_onCancelPayment);
|
||||||
on<ResetPaymentState>(_onResetPaymentState);
|
on<ResetPaymentState>(_onResetPaymentState);
|
||||||
|
on<RetryPaymentEvent>(_onRetryPayment);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onInitiatePayment(
|
Future<void> _onInitiatePayment(
|
||||||
InitiatePayment event,
|
InitiatePayment event,
|
||||||
Emitter<StripePaymentState> emit,
|
Emitter<StripePaymentState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
// 🛑 Prevent re-initialization if payment already completed
|
||||||
|
if (_paymentCompleted) {
|
||||||
|
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit(const StripePaymentLoading());
|
emit(const StripePaymentLoading(
|
||||||
|
message: 'Creating payment intent...',
|
||||||
|
));
|
||||||
|
|
||||||
/// Stripe expects smallest currency unit
|
/// Stripe expects smallest currency unit
|
||||||
/// USD → cents, INR → paise
|
/// USD → cents, INR → paise
|
||||||
@@ -35,6 +48,10 @@ class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
|
|||||||
currency: event.currency,
|
currency: event.currency,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
emit(const StripePaymentLoading(
|
||||||
|
message: 'Initializing payment sheet...',
|
||||||
|
));
|
||||||
|
|
||||||
// 2️⃣ Init Payment Sheet
|
// 2️⃣ Init Payment Sheet
|
||||||
await Stripe.instance.initPaymentSheet(
|
await Stripe.instance.initPaymentSheet(
|
||||||
paymentSheetParameters: SetupPaymentSheetParameters(
|
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
|
// 3️⃣ Show Payment Sheet
|
||||||
await Stripe.instance.presentPaymentSheet();
|
await Stripe.instance.presentPaymentSheet();
|
||||||
|
|
||||||
// ✅ SUCCESS
|
// ✅ SUCCESS - Mark as completed
|
||||||
|
_paymentCompleted = true;
|
||||||
emit(const StripePaymentSuccess());
|
emit(const StripePaymentSuccess());
|
||||||
} on StripeException catch (e) {
|
} on StripeException catch (e) {
|
||||||
// Handle Stripe-specific errors
|
_handleStripeException(e, emit);
|
||||||
if (e.error.code == FailureCode.Canceled) {
|
|
||||||
emit(StripePaymentCancelled(
|
|
||||||
message: e.error.localizedMessage ?? 'Payment Cancelled',
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
emit(StripePaymentFailure(
|
|
||||||
error: e.error.localizedMessage ?? 'Payment failed',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(StripePaymentFailure(
|
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(
|
Future<void> _onInitiatePaymentWithClientSecret(
|
||||||
InitiatePaymentWithClientSecret event,
|
InitiatePaymentWithClientSecret event,
|
||||||
Emitter<StripePaymentState> emit,
|
Emitter<StripePaymentState> emit,
|
||||||
) async {
|
) async {
|
||||||
|
// 🛑 Prevent re-initialization if payment already completed
|
||||||
|
if (_paymentCompleted) {
|
||||||
|
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit(const StripePaymentLoading());
|
emit(const StripePaymentLoading(
|
||||||
|
message: 'Initializing payment...',
|
||||||
|
));
|
||||||
|
|
||||||
// 1️⃣ Init Payment Sheet with clientSecret from backend
|
// 1️⃣ Init Payment Sheet with clientSecret from backend
|
||||||
await Stripe.instance.initPaymentSheet(
|
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
|
// 2️⃣ Show Payment Sheet
|
||||||
await Stripe.instance.presentPaymentSheet();
|
await Stripe.instance.presentPaymentSheet();
|
||||||
|
|
||||||
// ✅ SUCCESS
|
// ✅ SUCCESS - Mark as completed
|
||||||
|
_paymentCompleted = true;
|
||||||
emit(const StripePaymentSuccess());
|
emit(const StripePaymentSuccess());
|
||||||
} on StripeException catch (e) {
|
} on StripeException catch (e) {
|
||||||
// Handle Stripe-specific errors
|
_handleStripeException(e, emit);
|
||||||
if (e.error.code == FailureCode.Canceled) {
|
|
||||||
emit(StripePaymentCancelled(
|
|
||||||
message: e.error.localizedMessage ?? 'Payment Cancelled',
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
emit(StripePaymentFailure(
|
|
||||||
error: e.error.localizedMessage ?? 'Payment failed',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(StripePaymentFailure(
|
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(
|
void _onResetPaymentState(
|
||||||
ResetPaymentState event,
|
ResetPaymentState event,
|
||||||
Emitter<StripePaymentState> emit,
|
Emitter<StripePaymentState> emit,
|
||||||
) {
|
) {
|
||||||
|
// 🔄 Reset completion flag
|
||||||
|
_paymentCompleted = false;
|
||||||
emit(const StripePaymentInitial());
|
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];
|
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 {
|
class InitiatePaymentWithClientSecret extends StripePaymentEvent {
|
||||||
final String clientSecret;
|
final String clientSecret;
|
||||||
|
|
||||||
@@ -32,6 +32,24 @@ class InitiatePaymentWithClientSecret extends StripePaymentEvent {
|
|||||||
List<Object?> get props => [clientSecret];
|
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 {
|
class ResetPaymentState extends StripePaymentEvent {
|
||||||
const ResetPaymentState();
|
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 => [];
|
List<Object?> get props => [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initial state before any payment action
|
||||||
class StripePaymentInitial extends StripePaymentState {
|
class StripePaymentInitial extends StripePaymentState {
|
||||||
const StripePaymentInitial();
|
const StripePaymentInitial();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Payment is being processed
|
||||||
class StripePaymentLoading extends StripePaymentState {
|
class StripePaymentLoading extends StripePaymentState {
|
||||||
const StripePaymentLoading();
|
final String? message;
|
||||||
}
|
|
||||||
|
|
||||||
class StripePaymentSuccess extends StripePaymentState {
|
const StripePaymentLoading({
|
||||||
final String message;
|
this.message,
|
||||||
|
|
||||||
const StripePaymentSuccess({
|
|
||||||
this.message = 'Payment Successful',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [message];
|
List<Object?> get props => [message];
|
||||||
}
|
}
|
||||||
|
|
||||||
class StripePaymentFailure extends StripePaymentState {
|
/// Payment sheet is initialized and ready to be presented
|
||||||
final String error;
|
class StripePaymentSheetReady extends StripePaymentState {
|
||||||
|
const StripePaymentSheetReady();
|
||||||
|
}
|
||||||
|
|
||||||
const StripePaymentFailure({
|
/// Payment was successful
|
||||||
required this.error,
|
class StripePaymentSuccess extends StripePaymentState {
|
||||||
|
final String message;
|
||||||
|
final String? paymentIntentId;
|
||||||
|
|
||||||
|
const StripePaymentSuccess({
|
||||||
|
this.message = 'Payment Successful',
|
||||||
|
this.paymentIntentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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 {
|
class StripePaymentCancelled extends StripePaymentState {
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
@@ -44,6 +67,30 @@ class StripePaymentCancelled extends StripePaymentState {
|
|||||||
this.message = 'Payment Cancelled',
|
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
|
@override
|
||||||
List<Object?> get props => [message];
|
List<Object?> get props => [message];
|
||||||
}
|
}
|
||||||
@@ -1,230 +1,475 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import '../bloc/stripe_payment_event.dart';
|
import '../bloc/stripe_payment_event.dart';
|
||||||
import '../bloc/stripe_payment_state.dart';
|
import '../bloc/stripe_payment_state.dart';
|
||||||
import '../repository/stripe_service.dart';
|
import '../repository/stripe_service.dart';
|
||||||
|
|
||||||
class StripePaymentView extends StatelessWidget {
|
/// 🎯 Reusable Stripe Payment Screen
|
||||||
const StripePaymentView({super.key});
|
///
|
||||||
|
/// 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
|
/// Amount to display (optional)
|
||||||
Widget build(BuildContext context) {
|
final double? amount;
|
||||||
final args =
|
|
||||||
ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final double amount = args['amount'];
|
/// Currency symbol (default: \$)
|
||||||
final String currency = args['currency'];
|
final String currencySymbol;
|
||||||
|
|
||||||
return BlocProvider(
|
/// Custom title for the payment screen
|
||||||
create: (context) => StripePaymentBloc(
|
final String? title;
|
||||||
stripeService: StripeService(),
|
|
||||||
),
|
|
||||||
child: StripePaymentViewContent(
|
|
||||||
amount: amount,
|
|
||||||
currency: currency,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class StripePaymentViewContent extends StatefulWidget {
|
/// Custom loading message
|
||||||
final double amount;
|
final String loadingMessage;
|
||||||
final String currency;
|
|
||||||
|
|
||||||
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,
|
super.key,
|
||||||
required this.amount,
|
required this.clientSecret,
|
||||||
required this.currency,
|
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
|
/// 🚀 Static method to show as bottom sheet
|
||||||
State<StripePaymentViewContent> createState() =>
|
static Future<bool?> showAsBottomSheet({
|
||||||
_StripePaymentViewContentState();
|
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> {
|
/// 🚀 Static method to show as full screen dialog
|
||||||
@override
|
static Future<bool?> showAsDialog({
|
||||||
void initState() {
|
required BuildContext context,
|
||||||
super.initState();
|
required String clientSecret,
|
||||||
// Automatically initiate payment when screen loads
|
double? amount,
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
String currencySymbol = '\$',
|
||||||
context.read<StripePaymentBloc>().add(
|
String? title,
|
||||||
InitiatePayment(
|
String loadingMessage = 'Processing payment...',
|
||||||
amount: widget.amount,
|
String successMessage = 'Payment Successful!',
|
||||||
currency: widget.currency,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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) {
|
listener: (context, state) {
|
||||||
if (state is StripePaymentSuccess) {
|
if (state is StripePaymentSuccess) {
|
||||||
// Show success message
|
debugPrint('✅ Payment Success - Calling callback');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// ✅ Call the callback first
|
||||||
SnackBar(
|
onPaymentSuccess?.call();
|
||||||
content: Text(state.message),
|
// ✅ Then auto-close and return true after 1.5 seconds
|
||||||
backgroundColor: Colors.green,
|
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||||
duration: const Duration(seconds: 2),
|
if (context.mounted) {
|
||||||
),
|
Navigator.of(context).pop(true);
|
||||||
);
|
|
||||||
// Return success to previous screen
|
|
||||||
Future.delayed(const Duration(milliseconds: 500), () {
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context, true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (state is StripePaymentFailure) {
|
} else if (state is StripePaymentFailure) {
|
||||||
// Show error message
|
debugPrint('❌ Payment Failure - ${state.error}');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
onPaymentFailure?.call(state.error);
|
||||||
SnackBar(
|
// Auto-close after 2 seconds on failure
|
||||||
content: Text(state.error),
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
backgroundColor: Colors.red,
|
if (context.mounted) {
|
||||||
duration: const Duration(seconds: 3),
|
Navigator.of(context).pop(false);
|
||||||
),
|
|
||||||
);
|
|
||||||
// Go back to checkout on error
|
|
||||||
Future.delayed(const Duration(seconds: 1), () {
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context, false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (state is StripePaymentCancelled) {
|
} else if (state is StripePaymentCancelled) {
|
||||||
// Show cancellation message
|
debugPrint('🚫 Payment Cancelled');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
onPaymentCancelled?.call();
|
||||||
SnackBar(
|
Navigator.of(context).pop(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
buildWhen: (previous, current) {
|
||||||
backgroundColor: Colors.white,
|
// 🔒 Prevent unnecessary rebuilds on duplicate success states
|
||||||
appBar: AppBar(
|
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
|
||||||
title: const Text("Processing Payment"),
|
return false;
|
||||||
backgroundColor: Colors.white,
|
}
|
||||||
elevation: 0,
|
return true;
|
||||||
automaticallyImplyLeading: false, // Remove back button during processing
|
},
|
||||||
centerTitle: true,
|
builder: (context, state) {
|
||||||
),
|
return Container(
|
||||||
body: BlocBuilder<StripePaymentBloc, StripePaymentState>(
|
height: heightRatio == 1.0
|
||||||
builder: (context, state) {
|
? MediaQuery.of(context).size.height
|
||||||
return Center(
|
: MediaQuery.of(context).size.height * heightRatio,
|
||||||
child: Padding(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.all(24.0),
|
color: Colors.white,
|
||||||
child: Column(
|
borderRadius: heightRatio == 1.0
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
? null
|
||||||
children: [
|
: const BorderRadius.only(
|
||||||
// Loading Indicator
|
topLeft: Radius.circular(20),
|
||||||
if (state is StripePaymentLoading) ...[
|
topRight: Radius.circular(20),
|
||||||
const CircularProgressIndicator(
|
),
|
||||||
strokeWidth: 3,
|
),
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
child: Stack(
|
||||||
Color(0xFFF95F62),
|
children: [
|
||||||
),
|
// Main content
|
||||||
),
|
Center(
|
||||||
const SizedBox(height: 24),
|
child: Padding(
|
||||||
const Text(
|
padding: const EdgeInsets.all(24.0),
|
||||||
"Preparing secure payment...",
|
child: Column(
|
||||||
style: TextStyle(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
fontSize: 16,
|
children: [
|
||||||
fontWeight: FontWeight.w500,
|
// Custom header widget
|
||||||
color: Color(0xFF333333),
|
if (headerWidget != null) ...[
|
||||||
),
|
headerWidget!,
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
// 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
|
// Handle API submission success
|
||||||
if (state is PurchaseDetailsSubmitted) {
|
if (state is PurchaseDetailsSubmitted) {
|
||||||
// Show success message
|
// Show success message
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
// const SnackBar(
|
||||||
content: Text('Gift details submitted successfully!'),
|
// content: Text('Gift details submitted successfully!'),
|
||||||
backgroundColor: Color(0xffF95F62),
|
// backgroundColor: Color(0xffF95F62),
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
|
|
||||||
// Navigate back
|
// Navigate back
|
||||||
Navigator.of(context).pop('success');
|
Navigator.of(context).pop('success');
|
||||||
@@ -231,7 +231,7 @@ class _AddDetailsViewState extends State<AddDetailsView> {
|
|||||||
selectedCountry = value;
|
selectedCountry = value;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
items: ["India", "USA", "UK", "Canada"]
|
items: ["Australia"]
|
||||||
.map((value) {
|
.map((value) {
|
||||||
return DropdownMenuItem<String>(
|
return DropdownMenuItem<String>(
|
||||||
value: value,
|
value: value,
|
||||||
|
|||||||
@@ -26,15 +26,18 @@ class ShareBottomSheet extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// drag handle
|
||||||
Container(
|
Container(
|
||||||
height: 4.h,
|
height: 4.h,
|
||||||
width: 47.w,
|
width: 47.w,
|
||||||
margin: EdgeInsets.only(bottom: 16),
|
margin: EdgeInsets.only(bottom: 16.h),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF222222),
|
color: const Color(0xFF222222),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// link field
|
||||||
TextField(
|
TextField(
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -51,7 +54,10 @@ class ShareBottomSheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
SizedBox(height: 20.h),
|
SizedBox(height: 20.h),
|
||||||
|
|
||||||
|
// grid
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
@@ -67,7 +73,16 @@ class ShareBottomSheet extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
SizedBox(height: 8.h),
|
||||||
Text(
|
Text(
|
||||||
item['title']!,
|
item['title']!,
|
||||||
@@ -78,26 +93,32 @@ class ShareBottomSheet extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// page indicator
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
4,
|
4,
|
||||||
(index) => Container(
|
(index) => Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
width: 8.w,
|
width: 8.w,
|
||||||
height: 8.h,
|
height: 8.h,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: index == 0 ? Color(0xFF676363) : Colors.white,
|
color: index == 0
|
||||||
border: Border.all(color: Color(0xFF676363)),
|
? const Color(0xFF676363)
|
||||||
|
: Colors.white,
|
||||||
|
border: Border.all(color: const Color(0xFF676363)),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
SizedBox(height: 10.h),
|
SizedBox(height: 10.h),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,9 +37,9 @@ class Attraction {
|
|||||||
final String title;
|
final String title;
|
||||||
final String description;
|
final String description;
|
||||||
final String urlSlug;
|
final String urlSlug;
|
||||||
final int cityXid;
|
final num cityXid;
|
||||||
final int cardTypeXid;
|
final num cardTypeXid;
|
||||||
final int partnerXid;
|
final num partnerXid;
|
||||||
final String productCode;
|
final String productCode;
|
||||||
|
|
||||||
final bool isBookingRequired;
|
final bool isBookingRequired;
|
||||||
@@ -47,14 +47,14 @@ class Attraction {
|
|||||||
final String bookingEmail;
|
final String bookingEmail;
|
||||||
final String bookingPhoneNumber;
|
final String bookingPhoneNumber;
|
||||||
|
|
||||||
final double latitudeCoordinate;
|
final num latitudeCoordinate;
|
||||||
final double longitudeCoordinate;
|
final num longitudeCoordinate;
|
||||||
final String address;
|
final String address;
|
||||||
|
|
||||||
final double? ticketPriceAdult;
|
final num? ticketPriceAdult;
|
||||||
final double? ticketPriceChild;
|
final num? ticketPriceChild;
|
||||||
final int durations;
|
final num durations;
|
||||||
final int groupSize;
|
final num groupSize;
|
||||||
final String ageRange;
|
final String ageRange;
|
||||||
|
|
||||||
final String seoTitle;
|
final String seoTitle;
|
||||||
@@ -115,13 +115,11 @@ class Attraction {
|
|||||||
isPartnerAccess: json['isPartnerAccess'] ?? false,
|
isPartnerAccess: json['isPartnerAccess'] ?? false,
|
||||||
bookingEmail: json['bookingEmail'] ?? '',
|
bookingEmail: json['bookingEmail'] ?? '',
|
||||||
bookingPhoneNumber: json['bookingPhonenumber'] ?? '',
|
bookingPhoneNumber: json['bookingPhonenumber'] ?? '',
|
||||||
latitudeCoordinate:
|
latitudeCoordinate: (json['latitudeCoordinate'] as num?) ?? 0,
|
||||||
(json['latitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
|
longitudeCoordinate: (json['longitudeCoordinate'] as num?) ?? 0,
|
||||||
longitudeCoordinate:
|
|
||||||
(json['longitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
|
|
||||||
address: json['address'] ?? '',
|
address: json['address'] ?? '',
|
||||||
ticketPriceAdult: (json['ticketPriceAdult'] as num?)?.toDouble(),
|
ticketPriceAdult: json['ticketPriceAdult'] as num?,
|
||||||
ticketPriceChild: (json['ticketPriceChild'] as num?)?.toDouble(),
|
ticketPriceChild: json['ticketPriceChild'] as num?,
|
||||||
durations: json['durations'] ?? 0,
|
durations: json['durations'] ?? 0,
|
||||||
groupSize: json['groupSize'] ?? 0,
|
groupSize: json['groupSize'] ?? 0,
|
||||||
ageRange: json['ageRange'] ?? '',
|
ageRange: json['ageRange'] ?? '',
|
||||||
@@ -197,9 +195,9 @@ class Attraction {
|
|||||||
class CardModel {
|
class CardModel {
|
||||||
final int id;
|
final int id;
|
||||||
final String title;
|
final String title;
|
||||||
final int cardTypeXid;
|
final num cardTypeXid;
|
||||||
final int adultPrice;
|
final num adultPrice;
|
||||||
final int childPrice;
|
final num childPrice;
|
||||||
final String cardStatus;
|
final String cardStatus;
|
||||||
|
|
||||||
CardModel({
|
CardModel({
|
||||||
@@ -234,7 +232,6 @@ class CardModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* -------------------- GALLERY -------------------- */
|
/* -------------------- GALLERY -------------------- */
|
||||||
|
|
||||||
class Gallery {
|
class Gallery {
|
||||||
@@ -275,7 +272,6 @@ class Gallery {
|
|||||||
bool get hasImage => filePathUrl.isNotEmpty;
|
bool get hasImage => filePathUrl.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* -------------------- CATEGORY -------------------- */
|
/* -------------------- CATEGORY -------------------- */
|
||||||
|
|
||||||
class Category {
|
class Category {
|
||||||
@@ -300,5 +296,4 @@ class Category {
|
|||||||
'categoryName': categoryName,
|
'categoryName': categoryName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ class AttractionCard extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pushNamed(
|
Navigator.of(context).pushNamed(
|
||||||
RouteConstants.attractionDetails,
|
RouteConstants.attractionDetails,
|
||||||
arguments: attraction,
|
arguments: attraction.id,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -61,6 +61,8 @@ class AttractionCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
attraction.title,
|
attraction.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16.sp,
|
fontSize: 16.sp,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -71,6 +73,8 @@ class AttractionCard extends StatelessWidget {
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
attraction.address,
|
attraction.address,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
fontSize: 12.sp,
|
fontSize: 12.sp,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
@@ -104,10 +108,8 @@ class AttractionCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
SizedBox(height: 6.h),
|
SizedBox(height: 6.h),
|
||||||
|
|
||||||
/// TAGS (CARD TITLES)
|
/// TAGS (CARD TITLES)
|
||||||
attraction.isBookingRequired == false
|
Wrap(
|
||||||
? Wrap(
|
|
||||||
spacing: 6.w,
|
spacing: 6.w,
|
||||||
runSpacing: 6.h,
|
runSpacing: 6.h,
|
||||||
children: tags
|
children: tags
|
||||||
@@ -145,27 +147,6 @@ class AttractionCard extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
.toList(),
|
.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());
|
json.encode(data.toJson());
|
||||||
|
|
||||||
class BuyPassModel {
|
class BuyPassModel {
|
||||||
final City city;
|
City city;
|
||||||
final List<Offer> offers;
|
List<Offer> offers;
|
||||||
final List<CardPass> cards;
|
List<CardPass> cards;
|
||||||
final List<Attraction> attractions;
|
List<Attraction> attractions;
|
||||||
|
|
||||||
BuyPassModel({
|
BuyPassModel({
|
||||||
required this.city,
|
required this.city,
|
||||||
@@ -20,41 +20,49 @@ class BuyPassModel {
|
|||||||
required this.attractions,
|
required this.attractions,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
|
factory BuyPassModel.fromJson(Map<String, dynamic>? json) {
|
||||||
|
json ??= {};
|
||||||
|
|
||||||
return BuyPassModel(
|
return BuyPassModel(
|
||||||
city: City.fromJson(json['city']),
|
city: City.fromJson(json['city']),
|
||||||
offers: List<Offer>.from(
|
offers: json['offers'] == null
|
||||||
json['offers'].map((x) => Offer.fromJson(x)),
|
? []
|
||||||
),
|
: List<Map<String, dynamic>>.from(json['offers'])
|
||||||
cards: List<CardPass>.from(
|
.map((e) => Offer.fromJson(e))
|
||||||
json['cards'].map((x) => CardPass.fromJson(x)),
|
.toList(),
|
||||||
),
|
cards: json['cards'] == null
|
||||||
attractions: List<Attraction>.from(
|
? []
|
||||||
json['attractions'].map((x) => Attraction.fromJson(x)),
|
: 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() => {
|
Map<String, dynamic> toJson() => {
|
||||||
"city": city.toJson(),
|
"city": city.toJson(),
|
||||||
"offers": offers.map((x) => x.toJson()).toList(),
|
"offers": offers.map((e) => e.toJson()).toList(),
|
||||||
"cards": cards.map((x) => x.toJson()).toList(),
|
"cards": cards.map((e) => e.toJson()).toList(),
|
||||||
"attractions": attractions.map((x) => x.toJson()).toList(),
|
"attractions": attractions.map((e) => e.toJson()).toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ---------- CITY ----------
|
/// ---------- CITY ----------
|
||||||
class City {
|
class City {
|
||||||
final int id;
|
int id;
|
||||||
final String name;
|
String name;
|
||||||
final String slug;
|
String slug;
|
||||||
final String tagLine;
|
String tagLine;
|
||||||
final String description;
|
String description;
|
||||||
final String bestTimeToVisit;
|
String bestTimeToVisit;
|
||||||
final String priceRange;
|
String priceRange;
|
||||||
final num individualTicketAmount; // Changed from int to num
|
num individualTicketAmount;
|
||||||
final num cityCardTicketAmount; // Changed from int to num
|
num cityCardTicketAmount;
|
||||||
final HeroBanner heroBanner;
|
HeroBanner heroBanner;
|
||||||
|
|
||||||
City({
|
City({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -69,17 +77,19 @@ class City {
|
|||||||
required this.heroBanner,
|
required this.heroBanner,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory City.fromJson(Map<String, dynamic> json) {
|
factory City.fromJson(Map<String, dynamic>? json) {
|
||||||
|
json ??= {};
|
||||||
|
|
||||||
return City(
|
return City(
|
||||||
id: json['id'],
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
name: json['name'],
|
name: json['name']?.toString() ?? "",
|
||||||
slug: json['slug'],
|
slug: json['slug']?.toString() ?? "",
|
||||||
tagLine: json['tagLine'],
|
tagLine: json['tagLine']?.toString() ?? "",
|
||||||
description: json['description'],
|
description: json['description']?.toString() ?? "",
|
||||||
bestTimeToVisit: json['bestTimeToVisit'],
|
bestTimeToVisit: json['bestTimeToVisit']?.toString() ?? "",
|
||||||
priceRange: json['priceRange'],
|
priceRange: json['priceRange']?.toString() ?? "",
|
||||||
individualTicketAmount: json['individualTicketAmount'],
|
individualTicketAmount: json['individualTicketAmount'] ?? 0,
|
||||||
cityCardTicketAmount: json['cityCardTicketAmount'],
|
cityCardTicketAmount: json['cityCardTicketAmount'] ?? 0,
|
||||||
heroBanner: HeroBanner.fromJson(json['heroBanner']),
|
heroBanner: HeroBanner.fromJson(json['heroBanner']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,18 +110,20 @@ class City {
|
|||||||
|
|
||||||
/// ---------- HERO BANNER ----------
|
/// ---------- HERO BANNER ----------
|
||||||
class HeroBanner {
|
class HeroBanner {
|
||||||
final String title;
|
String title;
|
||||||
final String image;
|
String image;
|
||||||
|
|
||||||
HeroBanner({
|
HeroBanner({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.image,
|
required this.image,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory HeroBanner.fromJson(Map<String, dynamic> json) {
|
factory HeroBanner.fromJson(Map<String, dynamic>? json) {
|
||||||
|
json ??= {};
|
||||||
|
|
||||||
return HeroBanner(
|
return HeroBanner(
|
||||||
title: json['title'],
|
title: json['title']?.toString() ?? "",
|
||||||
image: json['image'],
|
image: json['image']?.toString() ?? "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,25 +135,25 @@ class HeroBanner {
|
|||||||
|
|
||||||
/// ---------- OFFER ----------
|
/// ---------- OFFER ----------
|
||||||
class Offer {
|
class Offer {
|
||||||
final int id;
|
int id;
|
||||||
final String title;
|
String title;
|
||||||
final String offerCode;
|
String offerCode;
|
||||||
final String? description; // ✅ optional
|
String description;
|
||||||
final String? redemptionLink; // ✅ optional
|
String redemptionLink;
|
||||||
final String websiteBannerImage;
|
String websiteBannerImage;
|
||||||
final String mobileBannerImage;
|
String mobileBannerImage;
|
||||||
final String passType;
|
String passType;
|
||||||
final DateTime startDateTime;
|
DateTime startDateTime;
|
||||||
final DateTime endDateTime;
|
DateTime endDateTime;
|
||||||
final String offerStatus;
|
String offerStatus;
|
||||||
final bool applyToPasses;
|
bool applyToPasses;
|
||||||
|
|
||||||
Offer({
|
Offer({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.offerCode,
|
required this.offerCode,
|
||||||
this.description,
|
required this.description,
|
||||||
this.redemptionLink,
|
required this.redemptionLink,
|
||||||
required this.websiteBannerImage,
|
required this.websiteBannerImage,
|
||||||
required this.mobileBannerImage,
|
required this.mobileBannerImage,
|
||||||
required this.passType,
|
required this.passType,
|
||||||
@@ -151,20 +163,24 @@ class Offer {
|
|||||||
required this.applyToPasses,
|
required this.applyToPasses,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Offer.fromJson(Map<String, dynamic> json) {
|
factory Offer.fromJson(Map<String, dynamic>? json) {
|
||||||
|
json ??= {};
|
||||||
|
|
||||||
return Offer(
|
return Offer(
|
||||||
id: json['id'],
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
title: json['title'],
|
title: json['title']?.toString() ?? "",
|
||||||
offerCode: json['offerCode'],
|
offerCode: json['offerCode']?.toString() ?? "",
|
||||||
description: json['description'], // ✅
|
description: json['description']?.toString() ?? "",
|
||||||
redemptionLink: json['redemptionLink'], // ✅
|
redemptionLink: json['redemptionLink']?.toString() ?? "",
|
||||||
websiteBannerImage: json['websiteBannerImage'],
|
websiteBannerImage: json['websiteBannerImage']?.toString() ?? "",
|
||||||
mobileBannerImage: json['mobileBannerImage'],
|
mobileBannerImage: json['mobileBannerImage']?.toString() ?? "",
|
||||||
passType: json['passType'],
|
passType: json['passType']?.toString() ?? "",
|
||||||
startDateTime: DateTime.parse(json['startDateTime']),
|
startDateTime: DateTime.tryParse(json['startDateTime'] ?? "") ??
|
||||||
endDateTime: DateTime.parse(json['endDateTime']),
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
offerStatus: json['offerStatus'],
|
endDateTime: DateTime.tryParse(json['endDateTime'] ?? "") ??
|
||||||
applyToPasses: json['applyToPasses'],
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
offerStatus: json['offerStatus']?.toString() ?? "",
|
||||||
|
applyToPasses: json['applyToPasses'] ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,16 +202,16 @@ class Offer {
|
|||||||
|
|
||||||
/// ---------- CARD PASS ----------
|
/// ---------- CARD PASS ----------
|
||||||
class CardPass {
|
class CardPass {
|
||||||
final int id;
|
int id;
|
||||||
final String title;
|
String title;
|
||||||
final String description;
|
String description;
|
||||||
final int validityDuration;
|
int validityDuration;
|
||||||
final num adultPrice; // Changed from int to num
|
num adultPrice;
|
||||||
final num childPrice; // Changed from int to num
|
num childPrice;
|
||||||
final int minNumber; // ✅ NEW
|
int minNumber;
|
||||||
final int maxNumber; // ✅ NEW
|
int maxNumber;
|
||||||
final CardType cardType;
|
CardType cardType;
|
||||||
final List<Offer> offers;
|
List<Offer> offers;
|
||||||
|
|
||||||
CardPass({
|
CardPass({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -210,20 +226,24 @@ class CardPass {
|
|||||||
required this.offers,
|
required this.offers,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CardPass.fromJson(Map<String, dynamic> json) {
|
factory CardPass.fromJson(Map<String, dynamic>? json) {
|
||||||
|
json ??= {};
|
||||||
|
|
||||||
return CardPass(
|
return CardPass(
|
||||||
id: json['id'],
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
title: json['title'],
|
title: json['title']?.toString() ?? "",
|
||||||
description: json['description'],
|
description: json['description']?.toString() ?? "",
|
||||||
validityDuration: json['validityDuration'],
|
validityDuration: (json['validityDuration'] as num?)?.toInt() ?? 0,
|
||||||
adultPrice: json['adultPrice'],
|
adultPrice: json['adultPrice'] ?? 0,
|
||||||
childPrice: json['childPrice'],
|
childPrice: json['childPrice'] ?? 0,
|
||||||
minNumber: json['minNumber'], // ✅
|
minNumber: (json['minNumber'] as num?)?.toInt() ?? 0,
|
||||||
maxNumber: json['maxNumber'], // ✅
|
maxNumber: (json['maxNumber'] as num?)?.toInt() ?? 0,
|
||||||
cardType: CardType.fromJson(json['cardType']),
|
cardType: CardType.fromJson(json['cardType']),
|
||||||
offers: List<Offer>.from(
|
offers: json['offers'] == null
|
||||||
json['offers'].map((x) => Offer.fromJson(x)),
|
? []
|
||||||
),
|
: List<Map<String, dynamic>>.from(json['offers'])
|
||||||
|
.map((e) => Offer.fromJson(e))
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,15 +257,15 @@ class CardPass {
|
|||||||
"minNumber": minNumber,
|
"minNumber": minNumber,
|
||||||
"maxNumber": maxNumber,
|
"maxNumber": maxNumber,
|
||||||
"cardType": cardType.toJson(),
|
"cardType": cardType.toJson(),
|
||||||
"offers": offers.map((x) => x.toJson()).toList(),
|
"offers": offers.map((e) => e.toJson()).toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ---------- CARD TYPE ----------
|
/// ---------- CARD TYPE ----------
|
||||||
class CardType {
|
class CardType {
|
||||||
final int id;
|
int id;
|
||||||
final String name;
|
String name;
|
||||||
final String displayName;
|
String displayName;
|
||||||
|
|
||||||
CardType({
|
CardType({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -253,11 +273,13 @@ class CardType {
|
|||||||
required this.displayName,
|
required this.displayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CardType.fromJson(Map<String, dynamic> json) {
|
factory CardType.fromJson(Map<String, dynamic>? json) {
|
||||||
|
json ??= {};
|
||||||
|
|
||||||
return CardType(
|
return CardType(
|
||||||
id: json['id'],
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
name: json['name'],
|
name: json['name']?.toString() ?? "",
|
||||||
displayName: json['displayName'],
|
displayName: json['displayName']?.toString() ?? "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,27 +292,29 @@ class CardType {
|
|||||||
|
|
||||||
/// ---------- ATTRACTION ----------
|
/// ---------- ATTRACTION ----------
|
||||||
class Attraction {
|
class Attraction {
|
||||||
final int id;
|
int id;
|
||||||
final String title;
|
String title;
|
||||||
final String slug;
|
String slug;
|
||||||
final String thumbnail;
|
String thumbnail;
|
||||||
final num? startingFrom; // Changed from int? to num?
|
num startingFrom;
|
||||||
|
|
||||||
Attraction({
|
Attraction({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.slug,
|
required this.slug,
|
||||||
required this.thumbnail,
|
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(
|
return Attraction(
|
||||||
id: json['id'],
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
title: json['title'],
|
title: json['title']?.toString() ?? "",
|
||||||
slug: json['slug'],
|
slug: json['slug']?.toString() ?? "",
|
||||||
thumbnail: json['thumbnail'],
|
thumbnail: json['thumbnail']?.toString() ?? "",
|
||||||
startingFrom: json['startingFrom'],
|
startingFrom: json['startingFrom'] ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,4 +325,4 @@ class Attraction {
|
|||||||
"thumbnail": thumbnail,
|
"thumbnail": thumbnail,
|
||||||
"startingFrom": startingFrom,
|
"startingFrom": startingFrom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ class BuyPassRepository {
|
|||||||
required int totalChild,
|
required int totalChild,
|
||||||
required int noOfAttractions,
|
required int noOfAttractions,
|
||||||
required int noOfDays,
|
required int noOfDays,
|
||||||
|
required double baseAmount,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _apiService.postApi(
|
final response = await _apiService.postApi(
|
||||||
url: ApiUrls.addToCartPasses, // add this key in ApiUrls
|
url: ApiUrls.addToCartPasses,
|
||||||
data: {
|
data: {
|
||||||
"cityXid": cityXid,
|
"cityXid": cityXid,
|
||||||
"cardTypeXid": cardTypeXid,
|
"cardTypeXid": cardTypeXid,
|
||||||
@@ -38,6 +39,8 @@ class BuyPassRepository {
|
|||||||
"cardMode": cardMode,
|
"cardMode": cardMode,
|
||||||
"totalAdult": totalAdult,
|
"totalAdult": totalAdult,
|
||||||
"totalChild": totalChild,
|
"totalChild": totalChild,
|
||||||
|
"baseAmount": baseAmount,
|
||||||
|
"taxAmount": 2, // Fixed tax amount
|
||||||
"noOfAttractions": noOfAttractions,
|
"noOfAttractions": noOfAttractions,
|
||||||
"noOfDays": noOfDays,
|
"noOfDays": noOfDays,
|
||||||
},
|
},
|
||||||
@@ -48,4 +51,4 @@ class BuyPassRepository {
|
|||||||
throw Exception('Failed to add passes to cart: $e');
|
throw Exception('Failed to add passes to cart: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,10 +401,10 @@ class BuyPassContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Navigator.of(context).pushNamed(
|
Navigator.of(context).pushNamed(
|
||||||
// RouteConstants.attractionDetails,
|
RouteConstants.attractionDetails,
|
||||||
// arguments: attraction,
|
arguments: attraction.id,
|
||||||
// );
|
);
|
||||||
},
|
},
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8.r),
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class PaymentCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(20.r),
|
borderRadius: BorderRadius.circular(20.r),
|
||||||
),
|
),
|
||||||
child: CustomText(
|
child: CustomText(
|
||||||
text: "$cardDisplayName Card",
|
text: cardDisplayName,
|
||||||
size: 12.sp,
|
size: 12.sp,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
weight: FontWeight.w500,
|
weight: FontWeight.w500,
|
||||||
@@ -181,11 +181,12 @@ class PaymentCard extends StatelessWidget {
|
|||||||
cityXid: cityXid,
|
cityXid: cityXid,
|
||||||
cardTypeXid: cardTypeXid,
|
cardTypeXid: cardTypeXid,
|
||||||
cardXid: cardXid,
|
cardXid: cardXid,
|
||||||
cardMode: isSelectivePass ? 'flexi' : 'fixed',
|
cardMode: isSelectivePass ? 'flexi' : 'unlimited',
|
||||||
totalAdult: adults,
|
totalAdult: adults,
|
||||||
totalChild: children,
|
totalChild: children,
|
||||||
noOfAttractions: isSelectivePass ? selectedValue : 0,
|
noOfAttractions: isSelectivePass ? selectedValue : 0,
|
||||||
noOfDays: isUnlimitedCard ? selectedValue : 0,
|
noOfDays: isUnlimitedCard ? selectedValue : 0,
|
||||||
|
baseAmount: totalPrice,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ✅ Extract bookingId from response
|
// ✅ Extract bookingId from response
|
||||||
|
|||||||
@@ -8,18 +8,122 @@ class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
|
|||||||
final MyPassCartRepository repository;
|
final MyPassCartRepository repository;
|
||||||
|
|
||||||
MyPassCartBloc({required this.repository}) : super(const MyPassCartInitial()) {
|
MyPassCartBloc({required this.repository}) : super(const MyPassCartInitial()) {
|
||||||
|
on<CheckLoginAndFetchEvent>(_onCheckLoginAndFetch);
|
||||||
on<FetchPassCartEvent>(_onFetchPassCart);
|
on<FetchPassCartEvent>(_onFetchPassCart);
|
||||||
on<ClearPassCartEvent>(_onClearPassCart);
|
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(
|
Future<void> _onFetchPassCart(
|
||||||
FetchPassCartEvent event,
|
FetchPassCartEvent event,
|
||||||
Emitter<MyPassCartState> emit,
|
Emitter<MyPassCartState> emit,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🔄 [BLOC] Fetching pass cart...');
|
print('📄 [BLOC] Fetching pass cart from local...');
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(const MyPassCartLoading());
|
emit(const MyPassCartLoading());
|
||||||
@@ -52,7 +156,7 @@ class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
|
|||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🔄 [BLOC] Clearing pass cart...');
|
print('📄 [BLOC] Clearing pass cart...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// You can add clearPassCart method to repository if needed
|
// You can add clearPassCart method to repository if needed
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ abstract class MyPassCartEvent extends Equatable {
|
|||||||
List<Object?> get props => [];
|
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
|
/// Event to fetch pass cart data from local database
|
||||||
class FetchPassCartEvent extends MyPassCartEvent {
|
class FetchPassCartEvent extends MyPassCartEvent {
|
||||||
const FetchPassCartEvent();
|
const FetchPassCartEvent();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../model/my_passes_cart_mode.dart';
|
||||||
|
|
||||||
abstract class MyPassCartState extends Equatable {
|
abstract class MyPassCartState extends Equatable {
|
||||||
const MyPassCartState();
|
const MyPassCartState();
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ class MyPassCartLoading extends MyPassCartState {
|
|||||||
const MyPassCartLoading();
|
const MyPassCartLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loaded state with cart data
|
/// Loaded state with cart data from local storage
|
||||||
class MyPassCartLoaded extends MyPassCartState {
|
class MyPassCartLoaded extends MyPassCartState {
|
||||||
final Map<String, dynamic> cartData;
|
final Map<String, dynamic> cartData;
|
||||||
|
|
||||||
@@ -27,6 +29,16 @@ class MyPassCartLoaded extends MyPassCartState {
|
|||||||
List<Object?> get props => [cartData];
|
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
|
/// Empty state when no cart data exists
|
||||||
class MyPassCartEmpty extends MyPassCartState {
|
class MyPassCartEmpty extends MyPassCartState {
|
||||||
const MyPassCartEmpty();
|
const MyPassCartEmpty();
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
// import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../model/pass_model.dart';
|
// import '../model/pass_model.dart';
|
||||||
|
//
|
||||||
abstract class PassEvent {}
|
// abstract class PassEvent {}
|
||||||
class LoadPasses extends PassEvent {}
|
// class LoadPasses extends PassEvent {}
|
||||||
|
//
|
||||||
abstract class PassState {}
|
// abstract class PassState {}
|
||||||
class PassLoading extends PassState {}
|
// class PassLoading extends PassState {}
|
||||||
class PassLoaded extends PassState {
|
// class PassLoaded extends PassState {
|
||||||
final List<PassModel> passes;
|
// final List<PassModel> passes;
|
||||||
final double subtotal;
|
// final double subtotal;
|
||||||
final double discountPercent;
|
// final double discountPercent;
|
||||||
final double total;
|
// final double total;
|
||||||
|
//
|
||||||
PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
|
// PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
class PassBloc extends Bloc<PassEvent, PassState> {
|
// class PassBloc extends Bloc<PassEvent, PassState> {
|
||||||
PassBloc() : super(PassLoading()) {
|
// PassBloc() : super(PassLoading()) {
|
||||||
on<LoadPasses>((event, emit) {
|
// on<LoadPasses>((event, emit) {
|
||||||
final passes = [
|
// final passes = [
|
||||||
PassModel(
|
// PassModel(
|
||||||
title: "Melbourne",
|
// title: "Melbourne",
|
||||||
imageUrl: "assets/images/city_melbourne.png",
|
// imageUrl: "assets/images/city_melbourne.png",
|
||||||
duration: "2 days",
|
// duration: "2 days",
|
||||||
adults: 3,
|
// adults: 3,
|
||||||
kids: 3,
|
// kids: 3,
|
||||||
quantity: 2,
|
// quantity: 2,
|
||||||
price: 49.50,
|
// price: 49.50,
|
||||||
discount: 7.2,
|
// discount: 7.2,
|
||||||
),
|
// ),
|
||||||
];
|
// ];
|
||||||
|
//
|
||||||
final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
|
// final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
|
||||||
final discountPercent = passes.first.discount;
|
// final discountPercent = passes.first.discount;
|
||||||
final total = subtotal - (subtotal * discountPercent / 100);
|
// final total = subtotal - (subtotal * discountPercent / 100);
|
||||||
emit(PassLoaded(passes, subtotal, discountPercent, total));
|
// 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 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../../localPreference/local_preference.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 {
|
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
|
/// Fetch pass cart data from local database
|
||||||
Future<Map<String, dynamic>?> fetchPassesCartByLocal() async {
|
Future<Map<String, dynamic>?> fetchPassesCartByLocal() async {
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🔄 [REPO] Fetching pass cart from local database...');
|
print('📄 [REPO] Fetching pass cart from local database...');
|
||||||
}
|
}
|
||||||
|
|
||||||
final passCartData = await LocalPreference.getPassCart();
|
final passCartData = await LocalPreference.getPassCart();
|
||||||
|
|
||||||
|
|
||||||
if (passCartData != null) {
|
if (passCartData != null) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('✅ [REPO] Pass cart retrieved successfully');
|
print('✅ [REPO] Pass cart retrieved successfully');
|
||||||
@@ -32,4 +53,31 @@ class MyPassCartRepository {
|
|||||||
rethrow;
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.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 '../../login/view/login_email_bottomsheet.dart';
|
||||||
import '../../common_packages/common_app_texts.dart';
|
import '../../common_packages/common_app_texts.dart';
|
||||||
import '../../localPreference/local_preference.dart';
|
import '../../localPreference/local_preference.dart';
|
||||||
@@ -24,12 +26,13 @@ class _MyPassesPageState extends State<MyPassesPage> {
|
|||||||
// For coupon/discount management
|
// For coupon/discount management
|
||||||
String? appliedCouponCode;
|
String? appliedCouponCode;
|
||||||
double discountPercentage = 0.0;
|
double discountPercentage = 0.0;
|
||||||
|
bool isPurchaseDetailsConfirmed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Fetch cart data when page loads
|
// Fetch cart data when page loads
|
||||||
context.read<MyPassCartBloc>().add(const FetchPassCartEvent());
|
context.read<MyPassCartBloc>().add(const CheckLoginAndFetchEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,36 +41,42 @@ class _MyPassesPageState extends State<MyPassesPage> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is MyPassCartLoading) {
|
if (state is MyPassCartLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
} else if (state is MyPassCartLoaded) {
|
}
|
||||||
final cartData = state.cartData;
|
|
||||||
|
|
||||||
// Extract data from cart
|
// ========== HANDLE API DATA (LOGGED IN USER) ==========
|
||||||
final String cityName = cartData['city_name'] as String? ?? '';
|
else if (state is MyPassCartApiLoaded) {
|
||||||
final String heroImage = cartData['hero_image'] as String? ?? '';
|
final apiCartData = state.apiCartData;
|
||||||
final String cardTypeName = cartData['card_type_name'] as String? ?? '';
|
|
||||||
final String cardDisplayName = cartData['card_display_name'] as String? ?? '';
|
if (apiCartData.cartItems.isEmpty) {
|
||||||
final int themeColor = cartData['theme_color'] as int? ?? 0xFFF95FAF;
|
return const Center(child: Text('Your cart is empty'));
|
||||||
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;
|
// Get first cart item (you can modify to handle multiple items)
|
||||||
final double childPrice = (cartData['child_price'] as num?)?.toDouble() ?? 0.0;
|
final cartItem = apiCartData.cartItems.first;
|
||||||
final int validityDuration = cartData['validity_duration'] as int? ?? 0;
|
|
||||||
final double totalPrice = (cartData['total_price'] as num?)?.toDouble() ?? 0.0;
|
// Extract data from API cart item
|
||||||
final String? description = cartData['description'] as String?;
|
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
|
// Calculate pricing
|
||||||
final double subtotal = totalPrice;
|
final double subtotal = cartItem.baseAmount.toDouble();
|
||||||
final double discountAmount = subtotal * (discountPercentage / 100);
|
final double discountAmount = cartItem.couponDiscountAmount.toDouble();
|
||||||
final double taxRate = 0.05; // 5% tax
|
|
||||||
final double totalBeforeTax = subtotal - discountAmount;
|
final double totalBeforeTax = subtotal - discountAmount;
|
||||||
final double taxAmount = totalBeforeTax * taxRate;
|
final double taxAmount = cartItem.totalTaxAmount.toDouble();
|
||||||
final double finalTotal = totalBeforeTax + taxAmount;
|
final double finalTotal = totalPrice;
|
||||||
|
|
||||||
// Determine if unlimited card
|
// Determine if unlimited card
|
||||||
final bool isUnlimitedCard = cardTypeName == "unlimited_card";
|
final bool isUnlimitedCard = cardTypeName.toLowerCase().contains("unlimited");
|
||||||
final String validityLabel = isUnlimitedCard
|
final String validityLabel = isUnlimitedCard
|
||||||
? "$validityDuration Days"
|
? "$validityDuration Days"
|
||||||
: "$validityDuration Attractions";
|
: "${cartItem.noOfAttractions} Attractions";
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -90,23 +99,7 @@ class _MyPassesPageState extends State<MyPassesPage> {
|
|||||||
topLeft: Radius.circular(8.r),
|
topLeft: Radius.circular(8.r),
|
||||||
bottomLeft: Radius.circular(8.r),
|
bottomLeft: Radius.circular(8.r),
|
||||||
),
|
),
|
||||||
child: heroImage.isNotEmpty
|
child: Image.asset(
|
||||||
? 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",
|
"assets/images/card_banner.png",
|
||||||
scale: 4,
|
scale: 4,
|
||||||
width: 105.w,
|
width: 105.w,
|
||||||
@@ -133,8 +126,460 @@ class _MyPassesPageState extends State<MyPassesPage> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * .5,
|
width: MediaQuery.of(context).size.width * .5,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
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: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -232,13 +677,6 @@ class _MyPassesPageState extends State<MyPassesPage> {
|
|||||||
fontSize: 16.sp,
|
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),
|
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(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
|||||||
on<FetchCheckoutCouponsEvent>(_onFetchCheckoutCoupons);
|
on<FetchCheckoutCouponsEvent>(_onFetchCheckoutCoupons);
|
||||||
on<ApplyCouponEvent>(_onApplyCoupon);
|
on<ApplyCouponEvent>(_onApplyCoupon);
|
||||||
on<RemoveCouponEvent>(_onRemoveCoupon);
|
on<RemoveCouponEvent>(_onRemoveCoupon);
|
||||||
|
on<ApplyCouponToBackendEvent>(_onApplyCouponToBackend); // 🆕 NEW
|
||||||
on<InitiatePaymentEvent>(_onInitiatePayment); // 🆕 NEW
|
on<InitiatePaymentEvent>(_onInitiatePayment); // 🆕 NEW
|
||||||
on<ConfirmPaymentEvent>(_onConfirmPayment); // 🆕 NEW
|
on<ConfirmPaymentEvent>(_onConfirmPayment); // 🆕 NEW
|
||||||
}
|
}
|
||||||
@@ -42,13 +43,77 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onRemoveCoupon(
|
Future<void> _onRemoveCoupon(
|
||||||
RemoveCouponEvent event,
|
RemoveCouponEvent event,
|
||||||
Emitter<CheckoutState> emit,
|
Emitter<CheckoutState> emit,
|
||||||
) {
|
) async {
|
||||||
if (state is CheckoutCouponsLoadedState) {
|
if (state is CheckoutCouponsLoadedState) {
|
||||||
final currentState = state as 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,
|
ConfirmPaymentEvent event,
|
||||||
Emitter<CheckoutState> emit,
|
Emitter<CheckoutState> emit,
|
||||||
) async {
|
) 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
|
// Show loading state
|
||||||
if (state is CheckoutCouponsLoadedState) {
|
if (state is CheckoutCouponsLoadedState) {
|
||||||
final currentState = state as CheckoutCouponsLoadedState;
|
final currentState = state as CheckoutCouponsLoadedState;
|
||||||
@@ -139,6 +213,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
|||||||
isConfirmingPayment: true,
|
isConfirmingPayment: true,
|
||||||
confirmationError: null,
|
confirmationError: null,
|
||||||
isPaymentConfirmed: false,
|
isPaymentConfirmed: false,
|
||||||
|
hasConfirmationBeenSent: true, // 🔒 Mark as sent
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
emit(CheckoutPaymentConfirmingState());
|
emit(CheckoutPaymentConfirmingState());
|
||||||
@@ -174,6 +249,7 @@ class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
|||||||
isConfirmingPayment: false,
|
isConfirmingPayment: false,
|
||||||
isPaymentConfirmed: false,
|
isPaymentConfirmed: false,
|
||||||
confirmationError: e.toString(),
|
confirmationError: e.toString(),
|
||||||
|
hasConfirmationBeenSent: false, // 🔓 Reset on error to allow retry
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
emit(CheckoutPaymentConfirmationErrorState(
|
emit(CheckoutPaymentConfirmationErrorState(
|
||||||
|
|||||||
@@ -8,8 +8,22 @@ class ApplyCouponEvent extends CheckoutEvent {
|
|||||||
final AllCouponsModel coupon;
|
final AllCouponsModel coupon;
|
||||||
ApplyCouponEvent({required this.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
|
/// 🆕 Initiate Payment Event
|
||||||
/// Triggered when user clicks "Pay" button
|
/// Triggered when user clicks "Pay" button
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
|||||||
final List<AllCouponsModel> coupons;
|
final List<AllCouponsModel> coupons;
|
||||||
final AllCouponsModel? appliedCoupon;
|
final AllCouponsModel? appliedCoupon;
|
||||||
|
|
||||||
|
// 🆕 Coupon application tracking
|
||||||
|
final bool isApplyingCoupon;
|
||||||
|
final String? couponError;
|
||||||
|
|
||||||
// 🆕 Payment-related fields
|
// 🆕 Payment-related fields
|
||||||
final bool isInitiatingPayment;
|
final bool isInitiatingPayment;
|
||||||
final String? clientSecret; // Stripe client secret
|
final String? clientSecret; // Stripe client secret
|
||||||
@@ -21,10 +25,13 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
|||||||
final bool isPaymentConfirmed;
|
final bool isPaymentConfirmed;
|
||||||
final String? confirmationError;
|
final String? confirmationError;
|
||||||
final Map<String, dynamic>? bookingDetails; // Full booking response after confirmation
|
final Map<String, dynamic>? bookingDetails; // Full booking response after confirmation
|
||||||
|
final bool hasConfirmationBeenSent; // 🔒 Prevent duplicate confirmation calls
|
||||||
|
|
||||||
CheckoutCouponsLoadedState({
|
CheckoutCouponsLoadedState({
|
||||||
required this.coupons,
|
required this.coupons,
|
||||||
this.appliedCoupon,
|
this.appliedCoupon,
|
||||||
|
this.isApplyingCoupon = false,
|
||||||
|
this.couponError,
|
||||||
this.isInitiatingPayment = false,
|
this.isInitiatingPayment = false,
|
||||||
this.clientSecret,
|
this.clientSecret,
|
||||||
this.bookingId,
|
this.bookingId,
|
||||||
@@ -33,12 +40,15 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
|||||||
this.isPaymentConfirmed = false,
|
this.isPaymentConfirmed = false,
|
||||||
this.confirmationError,
|
this.confirmationError,
|
||||||
this.bookingDetails,
|
this.bookingDetails,
|
||||||
|
this.hasConfirmationBeenSent = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
CheckoutCouponsLoadedState copyWith({
|
CheckoutCouponsLoadedState copyWith({
|
||||||
List<AllCouponsModel>? coupons,
|
List<AllCouponsModel>? coupons,
|
||||||
AllCouponsModel? appliedCoupon,
|
AllCouponsModel? appliedCoupon,
|
||||||
bool clearAppliedCoupon = false,
|
bool clearAppliedCoupon = false,
|
||||||
|
bool? isApplyingCoupon,
|
||||||
|
String? couponError,
|
||||||
bool? isInitiatingPayment,
|
bool? isInitiatingPayment,
|
||||||
String? clientSecret,
|
String? clientSecret,
|
||||||
int? bookingId,
|
int? bookingId,
|
||||||
@@ -48,10 +58,13 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
|||||||
String? confirmationError,
|
String? confirmationError,
|
||||||
bool clearClientSecret = false,
|
bool clearClientSecret = false,
|
||||||
Map<String, dynamic>? bookingDetails,
|
Map<String, dynamic>? bookingDetails,
|
||||||
|
bool? hasConfirmationBeenSent,
|
||||||
}) {
|
}) {
|
||||||
return CheckoutCouponsLoadedState(
|
return CheckoutCouponsLoadedState(
|
||||||
coupons: coupons ?? this.coupons,
|
coupons: coupons ?? this.coupons,
|
||||||
appliedCoupon: clearAppliedCoupon ? null : (appliedCoupon ?? this.appliedCoupon),
|
appliedCoupon: clearAppliedCoupon ? null : (appliedCoupon ?? this.appliedCoupon),
|
||||||
|
isApplyingCoupon: isApplyingCoupon ?? this.isApplyingCoupon,
|
||||||
|
couponError: couponError,
|
||||||
isInitiatingPayment: isInitiatingPayment ?? this.isInitiatingPayment,
|
isInitiatingPayment: isInitiatingPayment ?? this.isInitiatingPayment,
|
||||||
bookingId: bookingId ?? this.bookingId,
|
bookingId: bookingId ?? this.bookingId,
|
||||||
paymentError: paymentError,
|
paymentError: paymentError,
|
||||||
@@ -60,6 +73,7 @@ class CheckoutCouponsLoadedState extends CheckoutState {
|
|||||||
confirmationError: confirmationError,
|
confirmationError: confirmationError,
|
||||||
clientSecret: clearClientSecret ? null : (clientSecret ?? this.clientSecret),
|
clientSecret: clearClientSecret ? null : (clientSecret ?? this.clientSecret),
|
||||||
bookingDetails: bookingDetails ?? this.bookingDetails,
|
bookingDetails: bookingDetails ?? this.bookingDetails,
|
||||||
|
hasConfirmationBeenSent: hasConfirmationBeenSent ?? this.hasConfirmationBeenSent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ class PassPurchaseDetailsRepository {
|
|||||||
// Request body
|
// Request body
|
||||||
final requestBody = {
|
final requestBody = {
|
||||||
'isForSelf': isForSelf,
|
'isForSelf': isForSelf,
|
||||||
'recipientName': recipientFirstName ?? '',
|
'recipientFirstName': recipientFirstName ?? '',
|
||||||
// 'recipientLastName': recipientLastName ?? '',
|
'recipientLastName': recipientLastName ?? '',
|
||||||
'recipientEmail': recipientEmail ?? '',
|
'recipientEmail': recipientEmail ?? '',
|
||||||
'recipientPhone': recipientPhone ?? '',
|
'recipientPhone': recipientPhone ?? '',
|
||||||
// 'city': city ?? '',
|
'recipientCity': city ?? '',
|
||||||
// 'country': country ?? '',
|
'recipientCountry': country ?? '',
|
||||||
};
|
};
|
||||||
|
|
||||||
log('📦 Request Body: $requestBody');
|
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/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../StripePayment/bloc/stripe_payment_bloc.dart';
|
import '../../StripePayment/view/stripe_payment.dart';
|
||||||
import '../../StripePayment/bloc/stripe_payment_event.dart';
|
|
||||||
import '../../StripePayment/bloc/stripe_payment_state.dart';
|
|
||||||
import '../../StripePayment/repository/stripe_service.dart';
|
|
||||||
import '../../add_details/add_details_view.dart';
|
import '../../add_details/add_details_view.dart';
|
||||||
import '../../buy_a_pass/models/checkout_model.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 '../../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 '../widget/pass_purchase_details_bottomsheet.dart';
|
||||||
import '../repository/all_coupons_repository.dart';
|
import '../repository/all_coupons_repository.dart';
|
||||||
import '../repository/checkout_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 CheckoutData checkoutData;
|
||||||
final int bookingId;
|
final int bookingId;
|
||||||
final bool isPurchaseDetailsConfirmed;
|
final bool isPurchaseDetailsConfirmed;
|
||||||
@@ -118,232 +117,73 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
required this.onPurchaseDetailsChanged,
|
required this.onPurchaseDetailsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CheckoutContent> createState() => _CheckoutContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckoutContentState extends State<_CheckoutContent> {
|
||||||
|
bool _hasHandledPaymentResult = false;
|
||||||
/// 🆕 Handle payment flow with client secret
|
/// 🆕 Handle payment flow with client secret
|
||||||
Future<void> _handlePaymentFlow(BuildContext context, String clientSecret, int bookingId) async {
|
/// 🆕 Handle payment flow with client secret - SIMPLIFIED VERSION
|
||||||
// Show payment bottom sheet with BLoC
|
Future<void> _handlePaymentFlow(BuildContext context, String clientSecret, int bookingId,double finalTotal) async {
|
||||||
final paymentResult = await showModalBottomSheet<Map<String, dynamic>>(
|
final paymentSuccess = await StripePaymentScreen.showAsBottomSheet(
|
||||||
context: context,
|
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,
|
isDismissible: false,
|
||||||
enableDrag: false,
|
enableDrag: false,
|
||||||
isScrollControlled: true,
|
onPaymentSuccess: () {
|
||||||
backgroundColor: Colors.transparent,
|
context.read<CheckoutBloc>().add(
|
||||||
builder: (bottomSheetContext) {
|
ConfirmPaymentEvent(
|
||||||
return BlocProvider(
|
bookingId: bookingId,
|
||||||
create: (_) => StripePaymentBloc(stripeService: StripeService())
|
stripeStatus: 'succeeded',
|
||||||
..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)),
|
paymentStatus: 'success',
|
||||||
child: BlocConsumer<StripePaymentBloc, StripePaymentState>(
|
),
|
||||||
listener: (context, state) {
|
);
|
||||||
if (state is StripePaymentSuccess) {
|
},
|
||||||
// Return success with stripe status
|
onPaymentFailure: (error) {
|
||||||
Navigator.of(bottomSheetContext).pop({
|
context.read<CheckoutBloc>().add(
|
||||||
'success': true,
|
ConfirmPaymentEvent(
|
||||||
'stripeStatus': 'succeeded',
|
bookingId: bookingId,
|
||||||
'paymentStatus': 'success',
|
stripeStatus: 'failed',
|
||||||
});
|
paymentStatus: 'failed',
|
||||||
} else if (state is StripePaymentFailure) {
|
),
|
||||||
// Return failure with stripe status
|
);
|
||||||
Navigator.of(bottomSheetContext).pop({
|
},
|
||||||
'success': false,
|
onPaymentCancelled: () {
|
||||||
'stripeStatus': 'requires_payment_method',
|
Navigator.pop(context);
|
||||||
'paymentStatus': 'failed',
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
'error': state.error,
|
const SnackBar(
|
||||||
});
|
content: Text('Payment cancelled'),
|
||||||
} else if (state is StripePaymentCancelled) {
|
backgroundColor: Colors.orange,
|
||||||
// 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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle payment result
|
// ✅ USE paymentSuccess HERE
|
||||||
if (paymentResult != null) {
|
if (paymentSuccess == true && context.mounted) {
|
||||||
final success = paymentResult['success'] as bool? ?? false;
|
// Wait a moment for backend confirmation
|
||||||
final stripeStatus = paymentResult['stripeStatus'] as String? ?? 'unknown';
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
final paymentStatus = paymentResult['paymentStatus'] as String? ?? 'unknown';
|
|
||||||
|
|
||||||
if (success) {
|
// Navigate to home after successful payment
|
||||||
// Payment successful - confirm with backend
|
|
||||||
if (context.mounted) {
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
// context.read<CheckoutBloc>().add(
|
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
|
||||||
// ConfirmPaymentEvent(
|
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
|
||||||
// bookingId: bookingId,
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
// stripeStatus: stripeStatus,
|
|
||||||
// paymentStatus: paymentStatus,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Payment confirmed successfully!'),
|
content: Text('Payment confirmed successfully!'),
|
||||||
backgroundColor: Colors.green,
|
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) {
|
listener: (context, state) {
|
||||||
// 🆕 Listen for payment initiation success
|
// 🆕 Listen for payment initiation success
|
||||||
if (state is CheckoutCouponsLoadedState) {
|
if (state is CheckoutCouponsLoadedState) {
|
||||||
// Check if clientSecret is available (payment initiated)
|
// 🔒 CHECK: Prevent duplicate payment flow initiation
|
||||||
if (state.clientSecret != null && state.clientSecret!.isNotEmpty) {
|
if (state.clientSecret != null &&
|
||||||
// Trigger payment flow
|
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((_) {
|
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
|
// 🆕 Listen for payment confirmation success
|
||||||
if (state.isPaymentConfirmed) {
|
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
|
// Navigate to success page or back
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -426,11 +281,12 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
isConfirmingPayment = state.isConfirmingPayment;
|
isConfirmingPayment = state.isConfirmingPayment;
|
||||||
}
|
}
|
||||||
|
|
||||||
final num subtotal = checkoutData.totalPrice;
|
final num subtotal = widget.checkoutData.totalPrice;
|
||||||
final double discountAmount = subtotal * (discountPercentage / 100);
|
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 totalBeforeTax = subtotal - discountAmount;
|
||||||
final double taxAmount = totalBeforeTax * taxRate;
|
// final double taxAmount = totalBeforeTax * taxRate;
|
||||||
|
final double taxAmount = 2;
|
||||||
final double finalTotal = totalBeforeTax + taxAmount;
|
final double finalTotal = totalBeforeTax + taxAmount;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -469,7 +325,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: checkoutData.themeColor.withOpacity(0.2),
|
color: widget.checkoutData.themeColor.withOpacity(0.2),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(8.r),
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
),
|
),
|
||||||
@@ -484,9 +340,9 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
topLeft: Radius.circular(8.r),
|
topLeft: Radius.circular(8.r),
|
||||||
bottomLeft: Radius.circular(8.r),
|
bottomLeft: Radius.circular(8.r),
|
||||||
),
|
),
|
||||||
child: checkoutData.heroImage.isNotEmpty
|
child: widget.checkoutData.heroImage.isNotEmpty
|
||||||
? Image.network(
|
? Image.network(
|
||||||
checkoutData.heroImage,
|
widget.checkoutData.heroImage,
|
||||||
width: 105.w,
|
width: 105.w,
|
||||||
height: 140.h,
|
height: 140.h,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
@@ -506,7 +362,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
height: 24.w,
|
height: 24.w,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: checkoutData.themeColor,
|
color: widget.checkoutData.themeColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -525,7 +381,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// City Name
|
// City Name
|
||||||
CustomText(
|
CustomText(
|
||||||
text: checkoutData.cityName,
|
text: widget.checkoutData.cityName,
|
||||||
weight: FontWeight.w500,
|
weight: FontWeight.w500,
|
||||||
size: 16.sp,
|
size: 16.sp,
|
||||||
),
|
),
|
||||||
@@ -533,7 +389,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
|
|
||||||
// Validity (Days or Attractions)
|
// Validity (Days or Attractions)
|
||||||
CustomText(
|
CustomText(
|
||||||
text: checkoutData.validityLabel,
|
text: widget.checkoutData.validityLabel,
|
||||||
color: const Color(0xFF8E8E8E),
|
color: const Color(0xFF8E8E8E),
|
||||||
size: 12.sp,
|
size: 12.sp,
|
||||||
),
|
),
|
||||||
@@ -547,7 +403,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
MainAxisAlignment.spaceBetween,
|
MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
// Adults
|
// Adults
|
||||||
if (checkoutData.adultCount > 0)
|
if (widget.checkoutData.adultCount > 0)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Image.asset(
|
Image.asset(
|
||||||
@@ -557,7 +413,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
SizedBox(width: 4.w),
|
SizedBox(width: 4.w),
|
||||||
CustomText(
|
CustomText(
|
||||||
text:
|
text:
|
||||||
"${checkoutData.adultCount} adult${checkoutData.adultCount > 1 ? 's' : ''}",
|
"${widget.checkoutData.adultCount} adult${widget.checkoutData.adultCount > 1 ? 's' : ''}",
|
||||||
color: const Color(0xFF8E8E8E),
|
color: const Color(0xFF8E8E8E),
|
||||||
size: 12.sp,
|
size: 12.sp,
|
||||||
),
|
),
|
||||||
@@ -570,7 +426,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Children
|
// Children
|
||||||
if (checkoutData.childCount > 0) ...[
|
if (widget.checkoutData.childCount > 0) ...[
|
||||||
Image.asset(
|
Image.asset(
|
||||||
"assets/icons/kid.png",
|
"assets/icons/kid.png",
|
||||||
scale: 4,
|
scale: 4,
|
||||||
@@ -578,7 +434,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
SizedBox(width: 4.w),
|
SizedBox(width: 4.w),
|
||||||
CustomText(
|
CustomText(
|
||||||
text:
|
text:
|
||||||
"${checkoutData.childCount} Kid${checkoutData.childCount > 1 ? 's' : ''}",
|
"${widget.checkoutData.childCount} Kid${widget.checkoutData.childCount > 1 ? 's' : ''}",
|
||||||
color: const Color(0xFF8E8E8E),
|
color: const Color(0xFF8E8E8E),
|
||||||
size: 12.sp,
|
size: 12.sp,
|
||||||
),
|
),
|
||||||
@@ -591,7 +447,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
text: "\$${subtotal.toStringAsFixed(2)}",
|
text: "\$${subtotal.toStringAsFixed(2)}",
|
||||||
size: 24.sp,
|
size: 24.sp,
|
||||||
weight: FontWeight.w500,
|
weight: FontWeight.w500,
|
||||||
color: checkoutData.themeColor,
|
color: widget.checkoutData.themeColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -605,7 +461,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
width: 35.w,
|
width: 35.w,
|
||||||
height: 140.h,
|
height: 140.h,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: checkoutData.themeColor,
|
color: widget.checkoutData.themeColor,
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
bottomRight: Radius.circular(8.r),
|
bottomRight: Radius.circular(8.r),
|
||||||
topRight: Radius.circular(8.r),
|
topRight: Radius.circular(8.r),
|
||||||
@@ -615,7 +471,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
quarterTurns: -1,
|
quarterTurns: -1,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
checkoutData.cardDisplayName,
|
widget.checkoutData.cardDisplayName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
@@ -704,11 +560,18 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
builder: (_) => AllCouponsBottomsheet(
|
builder: (_) => AllCouponsBottomsheet(
|
||||||
onCouponSelected: (selectedCoupon) {
|
onCouponSelected: (selectedCoupon) {
|
||||||
|
final coupon = selectedCoupon as AllCouponsModel;
|
||||||
// Apply the selected coupon
|
// Apply the selected coupon
|
||||||
context.read<CheckoutBloc>().add(
|
context.read<CheckoutBloc>().add(
|
||||||
ApplyCouponEvent(
|
ApplyCouponEvent(
|
||||||
coupon: selectedCoupon),
|
coupon: selectedCoupon),
|
||||||
);
|
);
|
||||||
|
context.read<CheckoutBloc>().add(
|
||||||
|
ApplyCouponToBackendEvent(
|
||||||
|
bookingId: widget.bookingId,
|
||||||
|
couponCode: coupon.couponCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -740,13 +603,16 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (appliedCoupon != null) {
|
if (appliedCoupon != null) {
|
||||||
context
|
|
||||||
.read<CheckoutBloc>()
|
|
||||||
.add(RemoveCouponEvent());
|
|
||||||
} else if (state.coupons.isNotEmpty) {
|
|
||||||
context.read<CheckoutBloc>().add(
|
context.read<CheckoutBloc>().add(
|
||||||
ApplyCouponEvent(
|
RemoveCouponEvent(bookingId: widget.bookingId),
|
||||||
coupon: state.coupons[0]),
|
);
|
||||||
|
} 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),
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
),
|
),
|
||||||
child: CustomText(
|
child: CustomText(
|
||||||
text:
|
text: state.isApplyingCoupon
|
||||||
appliedCoupon != null ? "Remove" : "Apply",
|
? "Applying..."
|
||||||
|
: (appliedCoupon != null ? "Remove" : "Apply"),
|
||||||
color: const Color(0xFFF95F62),
|
color: const Color(0xFFF95F62),
|
||||||
size: 14.sp,
|
size: 14.sp,
|
||||||
),
|
),
|
||||||
@@ -868,32 +735,32 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
? () {} // Empty callback when disabled
|
? () {} // Empty callback when disabled
|
||||||
: () async {
|
: () async {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
if (isPurchaseDetailsConfirmed) {
|
if (widget.isPurchaseDetailsConfirmed) {
|
||||||
// 🆕 Initiate payment flow
|
// 🆕 Initiate payment flow
|
||||||
context.read<CheckoutBloc>().add(
|
context.read<CheckoutBloc>().add(
|
||||||
InitiatePaymentEvent(
|
InitiatePaymentEvent(
|
||||||
bookingId: bookingId),
|
bookingId: widget.bookingId),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Show purchase details bottom sheet
|
// Show purchase details bottom sheet
|
||||||
final result = await PassPurchaseBottomSheet.show(
|
final result = await PassPurchaseBottomSheet.show(
|
||||||
context, bookingId: bookingId);
|
context, bookingId: widget.bookingId);
|
||||||
|
|
||||||
// ✅ Handle 'Buy for Myself' - user submitted details
|
// ✅ Handle 'Buy for Myself' - user submitted details
|
||||||
if (result == 'success') {
|
if (result == 'success') {
|
||||||
onPurchaseDetailsChanged(true);
|
widget.onPurchaseDetailsChanged(true);
|
||||||
}
|
}
|
||||||
// ✅ Handle 'Gift the Pass' - navigate to AddDetailsView
|
// ✅ Handle 'Gift the Pass' - navigate to AddDetailsView
|
||||||
else if (result == 'gift') {
|
else if (result == 'gift') {
|
||||||
final giftResult = await Navigator.of(context).push<String>(
|
final giftResult = await Navigator.of(context).push<String>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => AddDetailsView(bookingId: bookingId),
|
builder: (_) => AddDetailsView(bookingId: widget.bookingId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If gift details were successfully submitted, mark as confirmed
|
// If gift details were successfully submitted, mark as confirmed
|
||||||
if (giftResult == 'success') {
|
if (giftResult == 'success') {
|
||||||
onPurchaseDetailsChanged(true);
|
widget.onPurchaseDetailsChanged(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -915,7 +782,7 @@ class _CheckoutContent extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
label: isLoggedIn
|
label: isLoggedIn
|
||||||
? (isPurchaseDetailsConfirmed
|
? (widget.isPurchaseDetailsConfirmed
|
||||||
? (isInitiatingPayment || isConfirmingPayment
|
? (isInitiatingPayment || isConfirmingPayment
|
||||||
? "Processing..."
|
? "Processing..."
|
||||||
: "Pay \$${finalTotal.toStringAsFixed(2)}")
|
: "Pay \$${finalTotal.toStringAsFixed(2)}")
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ class _PassPurchaseContent extends StatelessWidget {
|
|||||||
Navigator.of(context).pop('success');
|
Navigator.of(context).pop('success');
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
// const SnackBar(
|
||||||
content: Text('Details submitted successfully!'),
|
// content: Text('Details submitted successfully!'),
|
||||||
backgroundColor: Color(0xffF95F62),
|
// backgroundColor: Color(0xffF95F62),
|
||||||
),
|
// ),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle API submission error
|
// Handle API submission error
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
class CommonAppText {
|
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/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_empty_view.dart';
|
||||||
import 'package:citycards_customer/itinerary_creation/views/magic_itinerary_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/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/bloc/search_offers_listing_bloc.dart';
|
||||||
import 'package:citycards_customer/search_offers/view/search_offers_with_listing.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 '../common_bloc/bottom_navigation_bloc.dart';
|
||||||
import '../home/views/home_page_view.dart';
|
import '../home/views/home_page_view.dart';
|
||||||
import '../home/views/registered_user_home_page.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/contact_us/contact_us_view.dart';
|
||||||
import '../profile/view/edit_profile/edit_profile_view.dart';
|
import '../profile/view/edit_profile/edit_profile_view.dart';
|
||||||
import '../profile/view/faq/faq_view.dart';
|
import '../profile/view/faq/faq_view.dart';
|
||||||
@@ -70,6 +77,24 @@ class AppRouter {
|
|||||||
case RouteConstants.attractionsPage:
|
case RouteConstants.attractionsPage:
|
||||||
final args = settings.arguments as String;
|
final args = settings.arguments as String;
|
||||||
return MaterialPageRoute(builder: (_) => AttractionsPage(source: args));
|
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:
|
case RouteConstants.profile:
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
@@ -150,10 +175,18 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case RouteConstants.attractionDetails:
|
case RouteConstants.attractionDetails:
|
||||||
final attractionId = settings.arguments as Attraction;
|
final attractionId = settings.arguments as int;
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (_) {
|
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
|
final bookingId = settings.arguments as int; // or String
|
||||||
|
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (_) => CheckoutView(
|
builder: (_) => CheckoutView(bookingId: bookingId),
|
||||||
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:
|
case RouteConstants.addDetails:
|
||||||
final bookingId = settings.arguments as int;
|
final bookingId = settings.arguments as int;
|
||||||
|
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
return AddDetailsView(
|
return AddDetailsView(bookingId: bookingId);
|
||||||
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/core/route_constants.dart';
|
||||||
import 'package:citycards_customer/home/views/registered_user_home_page.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/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:citycards_customer/postcard/views/add_filter_step_page_view.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/bloc/itinerary_steps_selection_bloc.dart';
|
||||||
import '../itinerary_creation/views/itinerary_creation_view.dart';
|
import '../itinerary_creation/views/itinerary_creation_view.dart';
|
||||||
import '../itinerary_creation/views/magic_itinerary_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_page_view.dart';
|
||||||
import '../my_pass/views/booking_successful_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 '../offer_pass_detail/offer_pass_detail_view.dart';
|
||||||
import '../postcard/blocs/postcard_creation_bloc.dart';
|
import '../postcard/blocs/postcard_creation_bloc.dart';
|
||||||
import '../postcard/views/postcard_creation_page_view.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/offers_bloc.dart';
|
||||||
import '../search_offers/bloc/search_offers_listing_bloc.dart';
|
import '../search_offers/bloc/search_offers_listing_bloc.dart';
|
||||||
import '../search_offers/repository/offers_repository.dart';
|
import '../search_offers/repository/offers_repository.dart';
|
||||||
@@ -54,12 +64,38 @@ Widget buildOffstageNavigator(
|
|||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (_) => AttractionsPage(source: args),
|
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(
|
return MaterialPageRoute(
|
||||||
builder: (_) {
|
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)
|
// 🔹 Upload Photo Page (start of postcard creation flow)
|
||||||
case RouteConstants.uploadPhotoPage:
|
case RouteConstants.uploadPhotoPage:
|
||||||
@@ -124,12 +177,14 @@ Widget buildOffstageNavigator(
|
|||||||
);
|
);
|
||||||
|
|
||||||
case RouteConstants.qrPage:
|
case RouteConstants.qrPage:
|
||||||
|
final bookingId = settings.arguments as int;
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final previousBloc = BlocProvider.of<MyPassBloc>(context);
|
return BlocProvider(
|
||||||
return BlocProvider.value(
|
create: (context) => MyPassesDetailsBloc(
|
||||||
value: previousBloc,
|
repository: MyPassesDetailsRepository(),
|
||||||
child: const QrPassView(),
|
),
|
||||||
|
child: PassDetailsView(bookingId: bookingId),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class RouteConstants {
|
|||||||
static const String home = '/home';
|
static const String home = '/home';
|
||||||
static const String registeredUserHome = '/registeredUserHome';
|
static const String registeredUserHome = '/registeredUserHome';
|
||||||
static const String attractionsPage = "/attractions";
|
static const String attractionsPage = "/attractions";
|
||||||
|
static const String passAttractionsPage = "/passAttractionsPage";
|
||||||
static const String postCardPage = "/postcards";
|
static const String postCardPage = "/postcards";
|
||||||
static const String uploadPhotoPage = "/uploadPhoto";
|
static const String uploadPhotoPage = "/uploadPhoto";
|
||||||
static const String addFilterPage = "/addFilter";
|
static const String addFilterPage = "/addFilter";
|
||||||
@@ -27,7 +28,8 @@ class RouteConstants {
|
|||||||
static const String magicItineraryEmptyScreen = '/magicItineraryEmptyScreen';
|
static const String magicItineraryEmptyScreen = '/magicItineraryEmptyScreen';
|
||||||
static const String itineraryCreationStart = '/itineraryCreationStart';
|
static const String itineraryCreationStart = '/itineraryCreationStart';
|
||||||
static const String itineraryCreation = '/itineraryCreation';
|
static const String itineraryCreation = '/itineraryCreation';
|
||||||
static const String magicItineraryFilledScreen = "/magicItineraryFilledScreen";
|
static const String magicItineraryFilledScreen =
|
||||||
|
"/magicItineraryFilledScreen";
|
||||||
|
|
||||||
/**************************** ESIM Page *****************************************/
|
/**************************** ESIM Page *****************************************/
|
||||||
|
|
||||||
@@ -37,12 +39,14 @@ class RouteConstants {
|
|||||||
/**************************** Attraction Page *****************************************/
|
/**************************** Attraction Page *****************************************/
|
||||||
|
|
||||||
static const String attractionDetails ='/attractionDetails';
|
static const String attractionDetails ='/attractionDetails';
|
||||||
|
static const String passAttractionDetails ='/passAttractionDetails';
|
||||||
|
|
||||||
/**************************** By Pass Page Page *****************************************/
|
/**************************** By Pass Page Page *****************************************/
|
||||||
|
|
||||||
static const String buyPass ='/buyPass';
|
static const String buyPass = '/buyPass';
|
||||||
static const String checkout ='/checkout';
|
static const String checkout = '/checkout';
|
||||||
static const String searchOffer = '/searchOffer';
|
static const String searchOffer = '/searchOffer';
|
||||||
|
static const String searchPassOffer = '/searchPassOffer';
|
||||||
static const String createAcct = '/createAcct';
|
static const String createAcct = '/createAcct';
|
||||||
static const String addDetails = '/addDetails';
|
static const String addDetails = '/addDetails';
|
||||||
static const String offerPassDetail = "/offerPassDetail";
|
static const String offerPassDetail = "/offerPassDetail";
|
||||||
@@ -56,4 +60,5 @@ class RouteConstants {
|
|||||||
static const String qrPage = '/qrPage';
|
static const String qrPage = '/qrPage';
|
||||||
static const String makeBooking = '/makeBooking';
|
static const String makeBooking = '/makeBooking';
|
||||||
static const String bookingSuccessful = '/bookingSuccessful';
|
static const String bookingSuccessful = '/bookingSuccessful';
|
||||||
|
static const String editPostCard = '/editPostCard';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,21 @@ class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
|
|||||||
mobileNumber: event.mobileNumber,
|
mobileNumber: event.mobileNumber,
|
||||||
address1: event.address1,
|
address1: event.address1,
|
||||||
address2: event.address2,
|
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(
|
await LocalPreference.setTokens(
|
||||||
accessToken: userModel.accessToken,
|
accessToken: userModel.accessToken,
|
||||||
refreshToken: userModel.refreshToken,
|
refreshToken: userModel.refreshToken,
|
||||||
refreshTokenMaxAge: userModel.refreshTokenMaxAge,
|
refreshTokenMaxAge: userModel.refreshTokenMaxAge,
|
||||||
);
|
);
|
||||||
|
|
||||||
await LocalPreference.setUserDetails(
|
await LocalPreference.setUserDetails(
|
||||||
userId: userModel.user.id,
|
userId: userModel.user.id,
|
||||||
firstName: userModel.user.firstName,
|
firstName: userModel.user.firstName,
|
||||||
@@ -45,10 +52,12 @@ class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
|
|||||||
role: userModel.user.role,
|
role: userModel.user.role,
|
||||||
roleId: userModel.user.roleId,
|
roleId: userModel.user.roleId,
|
||||||
);
|
);
|
||||||
|
|
||||||
await LocalPreference.setProfileImage(userModel.user.profileImage);
|
await LocalPreference.setProfileImage(userModel.user.profileImage);
|
||||||
|
|
||||||
emit(CreateAccountSuccess(
|
emit(CreateAccountSuccess(
|
||||||
message: response['message'] ?? 'Account created successfully',
|
message: 'Account created successfully',
|
||||||
userData: response['data'] ?? {},
|
userData: response,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(CreateAccountFailure(
|
emit(CreateAccountFailure(
|
||||||
@@ -63,4 +72,4 @@ class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
|
|||||||
) {
|
) {
|
||||||
emit(const CreateAccountInitial());
|
emit(const CreateAccountInitial());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class CreateAccountSubmitted extends CreateAccountEvent {
|
|||||||
final String mobileNumber;
|
final String mobileNumber;
|
||||||
final String address1;
|
final String address1;
|
||||||
final String address2;
|
final String address2;
|
||||||
|
final String city;
|
||||||
|
final String state;
|
||||||
|
final String country;
|
||||||
|
final String postalCode;
|
||||||
|
|
||||||
const CreateAccountSubmitted({
|
const CreateAccountSubmitted({
|
||||||
required this.firstName,
|
required this.firstName,
|
||||||
@@ -22,6 +26,10 @@ class CreateAccountSubmitted extends CreateAccountEvent {
|
|||||||
required this.mobileNumber,
|
required this.mobileNumber,
|
||||||
required this.address1,
|
required this.address1,
|
||||||
required this.address2,
|
required this.address2,
|
||||||
|
required this.city,
|
||||||
|
required this.state,
|
||||||
|
required this.country,
|
||||||
|
required this.postalCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,9 +40,13 @@ class CreateAccountSubmitted extends CreateAccountEvent {
|
|||||||
mobileNumber,
|
mobileNumber,
|
||||||
address1,
|
address1,
|
||||||
address2,
|
address2,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
country,
|
||||||
|
postalCode,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
class CreateAccountReset extends CreateAccountEvent {
|
class CreateAccountReset extends CreateAccountEvent {
|
||||||
const CreateAccountReset();
|
const CreateAccountReset();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,25 @@ class CreateAccountRepository {
|
|||||||
required String mobileNumber,
|
required String mobileNumber,
|
||||||
required String address1,
|
required String address1,
|
||||||
required String address2,
|
required String address2,
|
||||||
|
required String city,
|
||||||
|
required String state,
|
||||||
|
required String country,
|
||||||
|
required String postalCode,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _apiServices.postApi(
|
final response = await _apiServices.postApi(
|
||||||
url: ApiUrls.createAccount,
|
url: ApiUrls.createAccount,
|
||||||
data: {
|
data: {
|
||||||
'firstName': firstName,
|
"firstName": firstName,
|
||||||
'lastName': lastName,
|
"lastName": lastName,
|
||||||
'emailAddress': emailAddress,
|
"emailAddress": emailAddress,
|
||||||
'mobileNumber': mobileNumber,
|
"mobileNumber": mobileNumber,
|
||||||
'address1': address1,
|
"address1": address1,
|
||||||
'address2': address2,
|
"address2": address2,
|
||||||
|
"city": city,
|
||||||
|
"state": state,
|
||||||
|
"country": country,
|
||||||
|
"postalCode": postalCode,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -30,4 +38,4 @@ class CreateAccountRepository {
|
|||||||
throw Exception('Failed to create account: $e');
|
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/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 '../../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_bloc.dart';
|
||||||
import '../../profile/bloc/profile/profile_event.dart';
|
import '../../profile/bloc/profile/profile_event.dart';
|
||||||
import '../bloc/create_account_bloc.dart';
|
import '../bloc/create_account_bloc.dart';
|
||||||
@@ -13,22 +19,36 @@ import '../bloc/create_account_event.dart';
|
|||||||
import '../bloc/create_account_state.dart';
|
import '../bloc/create_account_state.dart';
|
||||||
import '../repository/create_account_repository.dart';
|
import '../repository/create_account_repository.dart';
|
||||||
|
|
||||||
class CreateAccountView extends StatelessWidget {
|
class CreateAccountView extends StatefulWidget {
|
||||||
final String email;
|
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 firstNameController = TextEditingController();
|
||||||
final TextEditingController lastNameController = TextEditingController();
|
final TextEditingController lastNameController = TextEditingController();
|
||||||
final TextEditingController emailController = TextEditingController();
|
final TextEditingController emailController = TextEditingController();
|
||||||
final TextEditingController phoneController = TextEditingController();
|
final TextEditingController phoneController = TextEditingController();
|
||||||
final TextEditingController addressController = TextEditingController();
|
final TextEditingController addressController = TextEditingController();
|
||||||
|
final TextEditingController cityController = TextEditingController();
|
||||||
|
final TextEditingController postalController = TextEditingController();
|
||||||
|
|
||||||
|
String? selectedState;
|
||||||
|
String? selectedCountry;
|
||||||
|
|
||||||
void _submitForm(BuildContext context) {
|
void _submitForm(BuildContext context) {
|
||||||
if (firstNameController.text.trim().isEmpty ||
|
if (firstNameController.text.trim().isEmpty ||
|
||||||
lastNameController.text.trim().isEmpty ||
|
lastNameController.text.trim().isEmpty ||
|
||||||
emailController.text.trim().isEmpty ||
|
emailController.text.trim().isEmpty ||
|
||||||
phoneController.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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Please fill all fields')),
|
const SnackBar(content: Text('Please fill all fields')),
|
||||||
);
|
);
|
||||||
@@ -43,28 +63,49 @@ class CreateAccountView extends StatelessWidget {
|
|||||||
mobileNumber: phoneController.text.trim(),
|
mobileNumber: phoneController.text.trim(),
|
||||||
address1: addressController.text.trim(),
|
address1: addressController.text.trim(),
|
||||||
address2: '',
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
emailController.text = email;
|
emailController.text = widget.email;
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => CreateAccountBloc(
|
create: (context) =>
|
||||||
repository: CreateAccountRepository(),
|
CreateAccountBloc(repository: CreateAccountRepository()),
|
||||||
),
|
|
||||||
child: BlocListener<CreateAccountBloc, CreateAccountState>(
|
child: BlocListener<CreateAccountBloc, CreateAccountState>(
|
||||||
listener: (context, state) async {
|
listener: (ctx, state) async {
|
||||||
if (state is CreateAccountSuccess) {
|
if (state is CreateAccountSuccess) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(state.message)),
|
|
||||||
);
|
|
||||||
await LocalPreference.setLogin(true);
|
await LocalPreference.setLogin(true);
|
||||||
final userId = await LocalPreference.getUserId();
|
final userId = await LocalPreference.getUserId();
|
||||||
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
|
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
|
||||||
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
|
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);
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(state.message)));
|
||||||
} else if (state is CreateAccountFailure) {
|
} else if (state is CreateAccountFailure) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -169,14 +210,157 @@ class CreateAccountView extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||||
child: CustomTextField(
|
child: CustomTextField(
|
||||||
label: "Address 1",
|
label: "Address",
|
||||||
hint: "Enter address manually or tap to search",
|
hint: "Enter address manually or tap to search",
|
||||||
controller: addressController,
|
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),
|
SizedBox(height: 20.h),
|
||||||
|
|
||||||
BlocBuilder<CreateAccountBloc, CreateAccountState>(
|
BlocBuilder<CreateAccountBloc, CreateAccountState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is CreateAccountLoading) {
|
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) {
|
factory CitySelectionResponse.fromJson(Map<String, dynamic> json) {
|
||||||
return CitySelectionResponse(
|
return CitySelectionResponse(
|
||||||
cities: (json['cities'] as List<dynamic>?)
|
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() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -20,33 +21,54 @@ class CitySelectionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CitySelection {
|
class CitySelection {
|
||||||
|
// 🔹 EXISTING FIELDS (UNCHANGED)
|
||||||
final int id;
|
final int id;
|
||||||
final String cityName;
|
final String cityName;
|
||||||
final String bannerImage;
|
final String bannerImage;
|
||||||
|
|
||||||
|
// 🔹 NEW FIELDS (ADDED ONLY)
|
||||||
|
final String cityIconPath;
|
||||||
|
final CityIcon? icon;
|
||||||
|
|
||||||
CitySelection({
|
CitySelection({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.cityName,
|
required this.cityName,
|
||||||
required this.bannerImage,
|
required this.bannerImage,
|
||||||
|
|
||||||
|
// 🔹 ADDED
|
||||||
|
required this.cityIconPath,
|
||||||
|
required this.icon,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CitySelection.fromJson(Map<String, dynamic> json) {
|
factory CitySelection.fromJson(Map<String, dynamic> json) {
|
||||||
return CitySelection(
|
return CitySelection(
|
||||||
|
// 🔹 EXISTING
|
||||||
id: json['id'] as int? ?? 0,
|
id: json['id'] as int? ?? 0,
|
||||||
cityName: json['cityName'] as String? ?? '',
|
cityName: json['cityName'] as String? ?? '',
|
||||||
bannerImage: json['bannerImage'] 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() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
|
// 🔹 EXISTING
|
||||||
'id': id,
|
'id': id,
|
||||||
'cityName': cityName,
|
'cityName': cityName,
|
||||||
'bannerImage': bannerImage,
|
'bannerImage': bannerImage,
|
||||||
|
|
||||||
|
// 🔹 ADDED
|
||||||
|
'cityIconPath': cityIconPath,
|
||||||
|
'icon': icon?.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to get the image URL with fallback
|
// 🔹 EXISTING METHODS (UNCHANGED)
|
||||||
String getImageUrl() {
|
String getImageUrl() {
|
||||||
if (bannerImage.isEmpty || !bannerImage.startsWith('http')) {
|
if (bannerImage.isEmpty || !bannerImage.startsWith('http')) {
|
||||||
return 'assets/images/card_banner.png';
|
return 'assets/images/card_banner.png';
|
||||||
@@ -54,8 +76,26 @@ class CitySelection {
|
|||||||
return bannerImage;
|
return bannerImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to check if image is network image
|
|
||||||
bool isNetworkImage() {
|
bool isNetworkImage() {
|
||||||
return bannerImage.isNotEmpty && bannerImage.startsWith('http');
|
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.cityName,
|
||||||
city.isNetworkImage(),
|
city.isNetworkImage(),
|
||||||
selectedCityId,
|
selectedCityId,
|
||||||
|
city.cityIconPath,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -260,12 +261,15 @@ class _CitySelectionView extends StatelessWidget {
|
|||||||
String imageUrl,
|
String imageUrl,
|
||||||
String name,
|
String name,
|
||||||
bool isNetwork,
|
bool isNetwork,
|
||||||
int selectedCityId, // Add this parameter
|
int selectedCityId,
|
||||||
|
String? svgIcon,
|
||||||
|
// Add this parameter
|
||||||
) {
|
) {
|
||||||
final bool isSelected = cityId == selectedCityId; // Check if selected
|
final bool isSelected = cityId == selectedCityId; // Check if selected
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await LocalPreference.setSelectedCityId(cityId);
|
await LocalPreference.setSelectedCityId(cityId);
|
||||||
|
await LocalPreference.setSelectedCityLogo(svgIcon!);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.read<HomeBloc>().add(FetchHomeData());
|
context.read<HomeBloc>().add(FetchHomeData());
|
||||||
debugPrint("Selected City ID: $cityId");
|
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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../models/current_location_model.dart';
|
||||||
|
|
||||||
abstract class ItineraryDetailEvent {}
|
abstract class ItineraryDetailEvent {}
|
||||||
|
|
||||||
class AddDateToItinerary extends ItineraryDetailEvent {
|
class AddDateToItinerary extends ItineraryDetailEvent {
|
||||||
@@ -10,11 +13,17 @@ class AddDateToItinerary extends ItineraryDetailEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AddCityToItinerary extends ItineraryDetailEvent {
|
class AddCityToItinerary extends ItineraryDetailEvent {
|
||||||
final String city;
|
final ItineraryCityModel city;
|
||||||
|
|
||||||
AddCityToItinerary(this.city);
|
AddCityToItinerary(this.city);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AddAddressToItinerary extends ItineraryDetailEvent {
|
||||||
|
final CurrentLocationModel address;
|
||||||
|
|
||||||
|
AddAddressToItinerary(this.address);
|
||||||
|
}
|
||||||
|
|
||||||
class AddEnergyToItinerary extends ItineraryDetailEvent {
|
class AddEnergyToItinerary extends ItineraryDetailEvent {
|
||||||
final String energy;
|
final String energy;
|
||||||
|
|
||||||
@@ -65,7 +74,7 @@ class AddShoppingRating extends ItineraryDetailEvent {
|
|||||||
|
|
||||||
class ItineraryDetailState {
|
class ItineraryDetailState {
|
||||||
final String? selectedDate;
|
final String? selectedDate;
|
||||||
final String? selectedCity;
|
final ItineraryCityModel? selectedCity;
|
||||||
final String? selectedEnergy;
|
final String? selectedEnergy;
|
||||||
final String? withKid;
|
final String? withKid;
|
||||||
final String? selectedDietary;
|
final String? selectedDietary;
|
||||||
@@ -74,6 +83,7 @@ class ItineraryDetailState {
|
|||||||
final String? culturalRating;
|
final String? culturalRating;
|
||||||
final String? wildLifeRating;
|
final String? wildLifeRating;
|
||||||
final String? shoppingRating;
|
final String? shoppingRating;
|
||||||
|
final CurrentLocationModel? baseAdd;
|
||||||
|
|
||||||
ItineraryDetailState({
|
ItineraryDetailState({
|
||||||
this.selectedDate,
|
this.selectedDate,
|
||||||
@@ -86,19 +96,21 @@ class ItineraryDetailState {
|
|||||||
this.culturalRating,
|
this.culturalRating,
|
||||||
this.wildLifeRating,
|
this.wildLifeRating,
|
||||||
this.shoppingRating,
|
this.shoppingRating,
|
||||||
|
this.baseAdd,
|
||||||
});
|
});
|
||||||
|
|
||||||
ItineraryDetailState copyWith({
|
ItineraryDetailState copyWith({
|
||||||
String? selectedDate,
|
String? selectedDate,
|
||||||
String? selectedCity,
|
ItineraryCityModel? selectedCity,
|
||||||
String? selectedEnergy,
|
String? selectedEnergy,
|
||||||
String? withKid,
|
String? withKid,
|
||||||
String? selectedDietary,
|
String? selectedDietary,
|
||||||
String? museumRating,
|
String? museumRating,
|
||||||
String? scenicRating,
|
String? scenicRating,
|
||||||
String? culturalRating,
|
String? culturalRating,
|
||||||
String? wildLifeRating,
|
String? wildLifeRating,
|
||||||
String? shoppingRating,
|
String? shoppingRating,
|
||||||
|
CurrentLocationModel? baseAdd,
|
||||||
}) {
|
}) {
|
||||||
return ItineraryDetailState(
|
return ItineraryDetailState(
|
||||||
selectedDate: selectedDate ?? this.selectedDate,
|
selectedDate: selectedDate ?? this.selectedDate,
|
||||||
@@ -111,6 +123,7 @@ class ItineraryDetailState {
|
|||||||
culturalRating: culturalRating ?? this.culturalRating,
|
culturalRating: culturalRating ?? this.culturalRating,
|
||||||
wildLifeRating: wildLifeRating ?? this.wildLifeRating,
|
wildLifeRating: wildLifeRating ?? this.wildLifeRating,
|
||||||
shoppingRating: shoppingRating ?? this.shoppingRating,
|
shoppingRating: shoppingRating ?? this.shoppingRating,
|
||||||
|
baseAdd: baseAdd ?? this.baseAdd,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,15 +134,6 @@ class AddItineraryDetailBloc
|
|||||||
: super(
|
: super(
|
||||||
ItineraryDetailState(
|
ItineraryDetailState(
|
||||||
selectedDate: DateFormat('EEEE, MMMM d, yyyy').format(DateTime.now()),
|
selectedDate: DateFormat('EEEE, MMMM d, yyyy').format(DateTime.now()),
|
||||||
selectedCity: "Paris",
|
|
||||||
selectedEnergy: "",
|
|
||||||
withKid: "",
|
|
||||||
selectedDietary: "",
|
|
||||||
museumRating: "",
|
|
||||||
scenicRating: "",
|
|
||||||
culturalRating: "",
|
|
||||||
wildLifeRating: "",
|
|
||||||
shoppingRating: "",
|
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
on<AddDateToItinerary>((event, emit) {
|
on<AddDateToItinerary>((event, emit) {
|
||||||
@@ -137,10 +141,13 @@ class AddItineraryDetailBloc
|
|||||||
});
|
});
|
||||||
|
|
||||||
on<AddCityToItinerary>((event, emit) {
|
on<AddCityToItinerary>((event, emit) {
|
||||||
print("Selected city: ${event.city}");
|
|
||||||
emit(state.copyWith(selectedCity: event.city));
|
emit(state.copyWith(selectedCity: event.city));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
on<AddAddressToItinerary>((event, emit) {
|
||||||
|
emit(state.copyWith(baseAdd: event.address));
|
||||||
|
});
|
||||||
|
|
||||||
on<AddEnergyToItinerary>((event, emit) {
|
on<AddEnergyToItinerary>((event, emit) {
|
||||||
emit(state.copyWith(selectedEnergy: event.energy));
|
emit(state.copyWith(selectedEnergy: event.energy));
|
||||||
});
|
});
|
||||||
@@ -150,13 +157,6 @@ class AddItineraryDetailBloc
|
|||||||
});
|
});
|
||||||
|
|
||||||
on<AddDietaryToItinerary>((event, emit) {
|
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));
|
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_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_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_detail_bloc.dart';
|
||||||
import 'package:citycards_customer/itinerary_creation/bloc/itinerary_steps_selection_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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
class MenuItem {
|
class CitySelectionView extends StatefulWidget {
|
||||||
final int id;
|
const CitySelectionView({super.key});
|
||||||
final String label;
|
|
||||||
final String flag;
|
|
||||||
|
|
||||||
MenuItem(this.id, this.label, this.flag);
|
@override
|
||||||
|
State<CitySelectionView> createState() => _CitySelectionViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MenuItem> menuItems = [
|
class _CitySelectionViewState extends State<CitySelectionView> {
|
||||||
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"},
|
|
||||||
];
|
|
||||||
|
|
||||||
final TextEditingController cityController = TextEditingController();
|
final TextEditingController cityController = TextEditingController();
|
||||||
|
final GetItineraryCitiesBloc getItineraryCitiesBloc =
|
||||||
|
GetItineraryCitiesBloc();
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
getItineraryCitiesBloc.add(GetItineraryCities());
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -60,89 +43,6 @@ class CitySelectionView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(height: 32.h),
|
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),
|
SizedBox(height: 16.h),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topLeft,
|
alignment: Alignment.topLeft,
|
||||||
@@ -154,57 +54,86 @@ class CitySelectionView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10.h),
|
SizedBox(height: 10.h),
|
||||||
SizedBox(
|
BlocBuilder<GetItineraryCitiesBloc, GetItineraryCitiesState>(
|
||||||
height: 175.h,
|
bloc: getItineraryCitiesBloc,
|
||||||
child: BlocBuilder<AddItineraryDetailBloc, ItineraryDetailState>(
|
builder: (ctx, state1) {
|
||||||
builder: (context, state) {
|
if (state1 is GetItineraryCitiesLoading) {
|
||||||
return GridView.builder(
|
return Center(child: CircularProgressIndicator());
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
} else if (state1 is GetItineraryCitiesFailed) {
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
return Center(child: Text(state1.error));
|
||||||
crossAxisCount: 4,
|
} else if (state1 is GetItineraryCitiesSuccessfully &&
|
||||||
mainAxisSpacing: 16.h,
|
state1.cities.isEmpty) {
|
||||||
crossAxisSpacing: 16.w,
|
return Center(child: Text("Data not found"));
|
||||||
),
|
} else if (state1 is GetItineraryCitiesSuccessfully) {
|
||||||
itemCount: cityList.length,
|
return SizedBox(
|
||||||
itemBuilder: (context, index) {
|
height: 175.h,
|
||||||
final item = cityList[index];
|
child: BlocBuilder<AddItineraryDetailBloc, ItineraryDetailState>(
|
||||||
final isSelected = item['city'] == state.selectedCity;
|
builder: (context, state) {
|
||||||
return GestureDetector(
|
return GridView.builder(
|
||||||
onTap: () {
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
context.read<AddItineraryDetailBloc>().add(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
AddCityToItinerary(item['city'] ?? ""),
|
crossAxisCount: 4,
|
||||||
);
|
mainAxisSpacing: 16.h,
|
||||||
},
|
crossAxisSpacing: 16.w,
|
||||||
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(
|
itemCount: state1.cities.length,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
itemBuilder: (context, index) {
|
||||||
children: [
|
final item = state1.cities[index];
|
||||||
CustomText(text: item['flag'] ?? ""),
|
final isSelected = item == state.selectedCity;
|
||||||
SizedBox(height: 4.h),
|
return GestureDetector(
|
||||||
CustomText(
|
onTap: () {
|
||||||
text: item['city'] ?? "",
|
context.read<AddItineraryDetailBloc>().add(
|
||||||
size: 12.sp,
|
AddCityToItinerary(item),
|
||||||
color: Color(0xFF364153),
|
);
|
||||||
|
},
|
||||||
|
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),
|
SizedBox(height: 40.h),
|
||||||
CustomFilledButton(
|
CustomFilledButton(
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||||
import 'package:citycards_customer/itinerary_creation/bloc/itinerary_steps_selection_bloc.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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.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:geolocator/geolocator.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../../bloc/itinerary_detail_bloc.dart';
|
||||||
|
|
||||||
class CurrentLocationSelection extends StatefulWidget {
|
class CurrentLocationSelection extends StatefulWidget {
|
||||||
const CurrentLocationSelection({super.key});
|
const CurrentLocationSelection({super.key});
|
||||||
@@ -18,26 +23,72 @@ class CurrentLocationSelection extends StatefulWidget {
|
|||||||
class _CurrentLocationSelectionState extends State<CurrentLocationSelection> {
|
class _CurrentLocationSelectionState extends State<CurrentLocationSelection> {
|
||||||
final TextEditingController _controller = TextEditingController();
|
final TextEditingController _controller = TextEditingController();
|
||||||
LatLng? _currentLatLng;
|
LatLng? _currentLatLng;
|
||||||
|
bool loading = false;
|
||||||
|
|
||||||
Future<void> _getCurrentLocation() async {
|
Future<void> _getCurrentLocation() async {
|
||||||
LocationPermission permission = await Geolocator.requestPermission();
|
try {
|
||||||
if (permission == LocationPermission.denied ||
|
setState(() {
|
||||||
permission == LocationPermission.deniedForever) {
|
loading = true;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
});
|
||||||
const SnackBar(content: Text('Location permission denied')),
|
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(
|
Future<void> _getAddressFromLatLng(double lat, double lng) async {
|
||||||
desiredAccuracy: LocationAccuracy.high,
|
try {
|
||||||
);
|
final placemarks = await placemarkFromCoordinates(lat, lng);
|
||||||
|
|
||||||
setState(() {
|
if (placemarks.isNotEmpty) {
|
||||||
_currentLatLng = LatLng(position.latitude, position.longitude);
|
final place = placemarks.first;
|
||||||
_controller.text =
|
|
||||||
"Lat: ${position.latitude.toStringAsFixed(5)}, Lng: ${position.longitude.toStringAsFixed(5)}";
|
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
|
@override
|
||||||
@@ -98,32 +149,45 @@ class _CurrentLocationSelectionState extends State<CurrentLocationSelection> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 250.h,
|
height: 250.h,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
child: loading == true
|
||||||
child: Image.asset(
|
? Center(
|
||||||
"assets/images/attra_detail_map.png",
|
child: CircularProgressIndicator(
|
||||||
fit: BoxFit.cover,
|
color: Color(0xFFF95F62),
|
||||||
height: 236.h,
|
),
|
||||||
),
|
)
|
||||||
// child: GoogleMap(
|
: FlutterMap(
|
||||||
// initialCameraPosition: CameraPosition(
|
options: MapOptions(
|
||||||
// target: _currentLatLng!,
|
initialCenter: _currentLatLng!,
|
||||||
// zoom: 15,
|
initialZoom: 15,
|
||||||
// ),
|
),
|
||||||
// markers: {
|
children: [
|
||||||
// Marker(
|
TileLayer(
|
||||||
// markerId: const MarkerId("currentLocation"),
|
urlTemplate:
|
||||||
// position: _currentLatLng!,
|
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
// ),
|
subdomains: const ['a', 'b', 'c'],
|
||||||
// },
|
userAgentPackageName:
|
||||||
// myLocationEnabled: true,
|
'com.citycards.customer',
|
||||||
// myLocationButtonEnabled: false,
|
),
|
||||||
// ),
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
point: _currentLatLng!,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.location_pin,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: GestureDetector(
|
: GestureDetector(
|
||||||
onTap: () {
|
onTap: _getCurrentLocation,
|
||||||
_getCurrentLocation();
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 46.h,
|
height: 46.h,
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||||
@@ -155,6 +219,15 @@ class _CurrentLocationSelectionState extends State<CurrentLocationSelection> {
|
|||||||
// --- Continue button ---
|
// --- Continue button ---
|
||||||
CustomFilledButton(
|
CustomFilledButton(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
context.read<AddItineraryDetailBloc>().add(
|
||||||
|
AddAddressToItinerary(
|
||||||
|
CurrentLocationModel(
|
||||||
|
baseAdd: _controller.text,
|
||||||
|
lan: _currentLatLng?.latitude,
|
||||||
|
lat: _currentLatLng?.latitude,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
context.read<ItineraryStepNavigationBloc>().add(
|
context.read<ItineraryStepNavigationBloc>().add(
|
||||||
ItineraryStepNavigationNextEvent(),
|
ItineraryStepNavigationNextEvent(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,35 +27,35 @@ class DateSelectionView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(height: 32.h),
|
SizedBox(height: 32.h),
|
||||||
|
|
||||||
Container(
|
GestureDetector(
|
||||||
height: 90.h,
|
onTap: () {
|
||||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
_pickDate(context);
|
||||||
decoration: BoxDecoration(
|
},
|
||||||
color: Colors.white,
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(28),
|
height: 90.h,
|
||||||
border: Border.all(color: Color(0xFFF95F62), width: 1.1.w),
|
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
color: Colors.white,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(28),
|
||||||
GestureDetector(
|
border: Border.all(color: Color(0xFFF95F62), width: 1.1.w),
|
||||||
onTap: () {
|
),
|
||||||
_pickDate(context);
|
child: Row(
|
||||||
},
|
children: [
|
||||||
child: Image.asset("assets/icons/calender.png", scale: 4),
|
Image.asset("assets/icons/calender.png", scale: 4),
|
||||||
),
|
SizedBox(width: 16.w),
|
||||||
SizedBox(width: 16.w),
|
BlocBuilder<AddItineraryDetailBloc, ItineraryDetailState>(
|
||||||
BlocBuilder<AddItineraryDetailBloc, ItineraryDetailState>(
|
builder: (context, state) {
|
||||||
builder: (context, state) {
|
return CustomText(
|
||||||
return CustomText(
|
text: state.selectedDate ?? "",
|
||||||
text: state.selectedDate ?? "",
|
size: 14.sp,
|
||||||
size: 14.sp,
|
color: Color(0xFF101828),
|
||||||
color: Color(0xFF101828),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
const Spacer(),
|
||||||
const Spacer(),
|
Icon(Icons.check_circle, color: Color(0xFFF95F62)),
|
||||||
Icon(Icons.check_circle, color: Color(0xFFF95F62)),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 32.h),
|
SizedBox(height: 32.h),
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class ItineraryCompletionView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
_buildProfileRow(
|
_buildProfileRow(
|
||||||
"City",
|
"City",
|
||||||
state.selectedCity ?? "",
|
state.selectedCity!.cityName ?? "",
|
||||||
),
|
),
|
||||||
_buildProfileRow(
|
_buildProfileRow(
|
||||||
"Energy",
|
"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/bloc/itinerary_steps_selection_bloc.dart';
|
||||||
import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_steps/current_location_selection.dart';
|
import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_steps/current_location_selection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -105,7 +105,10 @@ class _ItineraryCreationPageState extends State<ItineraryCreationPage> {
|
|||||||
children: [
|
children: [
|
||||||
DateSelectionView(),
|
DateSelectionView(),
|
||||||
CurrentLocationSelection(),
|
CurrentLocationSelection(),
|
||||||
CitySelectionView(),
|
BlocProvider(
|
||||||
|
create: (context) => GetItineraryCitiesBloc(),
|
||||||
|
child: CitySelectionView(),
|
||||||
|
),
|
||||||
EnergySelectionView(),
|
EnergySelectionView(),
|
||||||
KidsSelectionView(),
|
KidsSelectionView(),
|
||||||
DietarySelectionView(),
|
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/common_packages/custom_text.dart';
|
||||||
import 'package:citycards_customer/core/route_constants.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/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:citycards_customer/postcard/widgets/dotted_border_container.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.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 '../../login/view/login_email_bottomsheet.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class MagicItineraryView extends StatefulWidget {
|
class MagicItineraryView extends StatefulWidget {
|
||||||
const MagicItineraryView({super.key});
|
const MagicItineraryView({super.key});
|
||||||
@@ -17,32 +21,19 @@ class MagicItineraryView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MagicItineraryViewState extends State<MagicItineraryView> {
|
class _MagicItineraryViewState extends State<MagicItineraryView> {
|
||||||
bool isLoggedIn = false;
|
|
||||||
bool isLoading = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_checkLoginStatus();
|
// Trigger login check and fetch on init
|
||||||
}
|
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
|
||||||
|
|
||||||
Future<void> _checkLoginStatus() async {
|
|
||||||
// final loginStatus = await LocalPreference.getLogin();
|
|
||||||
final loginStatus = true;
|
|
||||||
setState(() {
|
|
||||||
isLoggedIn = loginStatus;
|
|
||||||
isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Color(0xFFFFF5F5),
|
backgroundColor: Colors.white,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: isLoading
|
child: Padding(
|
||||||
? Center(child: CircularProgressIndicator())
|
|
||||||
: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -50,52 +41,91 @@ class _MagicItineraryViewState extends State<MagicItineraryView> {
|
|||||||
CommonAppBar(
|
CommonAppBar(
|
||||||
isWhiteLogo: false,
|
isWhiteLogo: false,
|
||||||
isProfilePage: false,
|
isProfilePage: false,
|
||||||
showDivider: false,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SizedBox(height: 24.h),
|
SizedBox(height: 24.h),
|
||||||
|
// BLoC Builder for all states
|
||||||
// Show different UI based on login status
|
BlocBuilder<GetItineraryBloc, GetItineraryState>(
|
||||||
if (isLoggedIn) ...[
|
builder: (context, state) {
|
||||||
ItineraryFilledCard(),
|
if (state is GetItineraryLoading) {
|
||||||
SizedBox(height: 32.h),
|
return Center(
|
||||||
CustomPaint(
|
child: Padding(
|
||||||
painter: DottedBorderPainter(),
|
padding: EdgeInsets.only(top: 100.h),
|
||||||
child: Container(
|
child: CircularProgressIndicator(),
|
||||||
width: double.infinity,
|
),
|
||||||
padding: EdgeInsets.symmetric(vertical: 24.h),
|
);
|
||||||
decoration: BoxDecoration(
|
} else if (state is GetItineraryNotLoggedIn) {
|
||||||
color: Color(0xFFF95F62).withOpacity(0.25),
|
return NotLoggedInItineraryView();
|
||||||
borderRadius: BorderRadius.circular(12.sp),
|
} else if (state is GetItineraryRequiresPass) {
|
||||||
),
|
return RequiresUnlimitedPassView();
|
||||||
child: Column(
|
} else if (state is GetItinerarySuccessfully) {
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
if (state.itineraries.isEmpty) {
|
||||||
|
return NoItineraryView();
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomText(
|
...state.itineraries.map((itinerary) {
|
||||||
text: "Plan your next adventure",
|
return Column(
|
||||||
color: Color(0xFF656565),
|
children: [
|
||||||
size: 14.sp,
|
ItineraryFilledCard(
|
||||||
),
|
itinerary: itinerary,
|
||||||
SizedBox(height: 16.h),
|
|
||||||
CustomFilledButton(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
ItineraryCreationStartPage(),
|
|
||||||
),
|
),
|
||||||
);
|
SizedBox(height: 16.h),
|
||||||
},
|
],
|
||||||
label: "Create My Itinerary",
|
);
|
||||||
showArrow: true,
|
}).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 if (state is GetItineraryFailed) {
|
||||||
),
|
return ErrorItineraryView(
|
||||||
] else ...[
|
error: state.error,
|
||||||
EmptyItineraryView(),
|
onRetry: () {
|
||||||
],
|
context
|
||||||
|
.read<GetItineraryBloc>()
|
||||||
|
.add(CheckLoginAndFetchItinerary());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Initial state
|
||||||
|
return SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -105,8 +135,8 @@ class _MagicItineraryViewState extends State<MagicItineraryView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class EmptyItineraryView extends StatelessWidget {
|
class NotLoggedInItineraryView extends StatelessWidget {
|
||||||
const EmptyItineraryView({super.key});
|
const NotLoggedInItineraryView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -116,7 +146,7 @@ class EmptyItineraryView extends StatelessWidget {
|
|||||||
|
|
||||||
// Illustration image - replace with your asset path
|
// Illustration image - replace with your asset path
|
||||||
Image.asset(
|
Image.asset(
|
||||||
"assets/images/not_login.png", // Replace with your actual asset path
|
"assets/images/not_login.png",
|
||||||
height: 300.h,
|
height: 300.h,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
@@ -151,9 +181,7 @@ class EmptyItineraryView extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12.r)),
|
||||||
top: Radius.circular(12.r),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
builder: (_) => const LoginEmailBottomsheet(),
|
builder: (_) => const LoginEmailBottomsheet(),
|
||||||
);
|
);
|
||||||
@@ -166,11 +194,215 @@ class EmptyItineraryView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ItineraryFilledCard extends StatelessWidget {
|
class RequiresUnlimitedPassView extends StatelessWidget {
|
||||||
const ItineraryFilledCard({super.key});
|
const RequiresUnlimitedPassView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Container(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h),
|
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -183,19 +415,23 @@ class ItineraryFilledCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
CustomText(
|
Expanded(
|
||||||
text: "Melbourne Unlimited Card",
|
child: CustomText(
|
||||||
size: 16.sp,
|
text: "$cityName Travel Plan",
|
||||||
weight: FontWeight.w500,
|
size: 16.sp,
|
||||||
|
weight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 2.h),
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 2.h),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF439F6E),
|
color: itinerary.isActive
|
||||||
|
? Color(0xFF439F6E)
|
||||||
|
: Colors.grey.shade400,
|
||||||
borderRadius: BorderRadius.circular(100.r),
|
borderRadius: BorderRadius.circular(100.r),
|
||||||
),
|
),
|
||||||
child: CustomText(
|
child: CustomText(
|
||||||
text: "Active",
|
text: itinerary.isActive ? "Active" : "Inactive",
|
||||||
size: 11.sp,
|
size: 11.sp,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
@@ -204,7 +440,7 @@ class ItineraryFilledCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(height: 4.h),
|
SizedBox(height: 4.h),
|
||||||
CustomText(
|
CustomText(
|
||||||
text: "Melbourne",
|
text: cityName,
|
||||||
size: 12.sp,
|
size: 12.sp,
|
||||||
color: Colors.black.withOpacity(0.4),
|
color: Colors.black.withOpacity(0.4),
|
||||||
),
|
),
|
||||||
@@ -213,7 +449,11 @@ class ItineraryFilledCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Image.asset("assets/icons/calender_filled.png", width: 16.sp),
|
Image.asset("assets/icons/calender_filled.png", width: 16.sp),
|
||||||
SizedBox(width: 4.w),
|
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),
|
SizedBox(height: 8.h),
|
||||||
@@ -226,7 +466,7 @@ class ItineraryFilledCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(width: 4.w),
|
SizedBox(width: 4.w),
|
||||||
CustomText(
|
CustomText(
|
||||||
text: "6 attractions",
|
text: "$totalAttractions attractions",
|
||||||
color: Color(0xFF8E8E8E),
|
color: Color(0xFF8E8E8E),
|
||||||
size: 12.sp,
|
size: 12.sp,
|
||||||
),
|
),
|
||||||
@@ -238,17 +478,34 @@ class ItineraryFilledCard extends StatelessWidget {
|
|||||||
Icon(Icons.watch_later, color: Color(0xFF8E8E8E), size: 16.sp),
|
Icon(Icons.watch_later, color: Color(0xFF8E8E8E), size: 16.sp),
|
||||||
SizedBox(width: 4.w),
|
SizedBox(width: 4.w),
|
||||||
CustomText(
|
CustomText(
|
||||||
text: "Created 1/15/2024",
|
text: "Created ${_formatDate(itinerary.createdAt)}",
|
||||||
color: Color(0xFF8E8E8E),
|
color: Color(0xFF8E8E8E),
|
||||||
size: 12.sp,
|
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),
|
SizedBox(height: 12.h),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context)
|
Navigator.of(context).pushReplacementNamed(
|
||||||
.pushReplacementNamed(RouteConstants.yourItinerary);
|
RouteConstants.yourItinerary,
|
||||||
|
arguments: itinerary,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 43.h,
|
height: 43.h,
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ class LocalDatabase {
|
|||||||
path,
|
path,
|
||||||
version: 1,
|
version: 1,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
/// CITY TABLE
|
|
||||||
await db.execute('''
|
|
||||||
CREATE TABLE selected_city (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
city_id INTEGER
|
|
||||||
)
|
|
||||||
''');
|
|
||||||
|
|
||||||
/// ONBOARDING TABLE
|
/// ONBOARDING TABLE
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE onboarding_state (
|
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 {
|
static Future<void> clearUserDetails() async {
|
||||||
final db = await LocalDatabase().database;
|
final db = await LocalDatabase().database;
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class _LoginEmailBottomsheetState extends State<LoginEmailBottomsheet> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (state is LoginError) {
|
} else if (state is LoginError) {
|
||||||
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(state.errorMessage),
|
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_filled_button.dart';
|
||||||
import 'package:citycards_customer/common_packages/custom_text.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/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_bloc.dart';
|
||||||
import 'package:citycards_customer/profile/bloc/profile/profile_event.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(FetchProfileEvent(userId: userId!));
|
||||||
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
|
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
|
||||||
context.read<MyPostCardBloc>().add(CheckLoginStatus());
|
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(RefreshDraftPostCards());
|
||||||
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
|
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
|
// User exists - navigate to home/dashboard
|
||||||
// Navigator.of(context).pushReplacementNamed(RouteConstants.home);
|
// Navigator.of(context).pushReplacementNamed(RouteConstants.home);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -56,7 +61,7 @@ class _VerifyOtpBottomsheetState extends State<VerifyOtpBottomsheet> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// User doesn't exist - navigate to create account
|
// 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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Please complete your profile'),
|
content: Text('Please complete your profile'),
|
||||||
@@ -72,6 +77,7 @@ class _VerifyOtpBottomsheetState extends State<VerifyOtpBottomsheet> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (state is VerifyOtpError) {
|
} else if (state is VerifyOtpError) {
|
||||||
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(state.errorMessage),
|
content: Text(state.errorMessage),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:citycards_customer/cart/blocs/postcard_bloc.dart';
|
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/core/route_constants.dart';
|
||||||
import 'package:citycards_customer/search_offers/bloc/offers_bloc.dart';
|
import 'package:citycards_customer/search_offers/bloc/offers_bloc.dart';
|
||||||
import 'package:citycards_customer/trail.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:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_stripe/flutter_stripe.dart'; // ADD THIS
|
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/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_bloc.dart';
|
||||||
import 'home/bloc/FirstTimeUserHome/first_time_user_home_event.dart';
|
import 'home/bloc/FirstTimeUserHome/first_time_user_home_event.dart';
|
||||||
import 'home/bloc/registeredHome/home_bloc.dart';
|
import 'home/bloc/registeredHome/home_bloc.dart';
|
||||||
import 'home/repository/first_time_user_home_repository.dart';
|
import 'home/repository/first_time_user_home_repository.dart';
|
||||||
import 'home/repository/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/bloc/login/login_bloc.dart';
|
||||||
import 'login/repository/login_repository.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/blocs/my_pass_bloc.dart';
|
||||||
|
import 'my_pass/repository/my_passes_repository.dart';
|
||||||
import 'postcard/blocs/myPostCards/my_postcard_bloc.dart';
|
import 'postcard/blocs/myPostCards/my_postcard_bloc.dart';
|
||||||
import 'postcard/repository/my_postcard_repository.dart';
|
import 'postcard/repository/my_postcard_repository.dart';
|
||||||
import 'profile/bloc/profile/profile_bloc.dart';
|
import 'profile/bloc/profile/profile_bloc.dart';
|
||||||
@@ -56,6 +63,12 @@ class MyApp extends StatelessWidget {
|
|||||||
BlocProvider<MyPassBloc>(
|
BlocProvider<MyPassBloc>(
|
||||||
create: (_) => MyPassBloc()..add(LoadMyPasses()),
|
create: (_) => MyPassBloc()..add(LoadMyPasses()),
|
||||||
),
|
),
|
||||||
|
BlocProvider<MyPassesBloc>(
|
||||||
|
create: (_) => MyPassesBloc(MyPassesRepository()),
|
||||||
|
),
|
||||||
|
BlocProvider<MyPassCartBloc>(
|
||||||
|
create: (_) => MyPassCartBloc(repository: MyPassCartRepository()),
|
||||||
|
),
|
||||||
BlocProvider<FirstTimeUserHomeBloc>(
|
BlocProvider<FirstTimeUserHomeBloc>(
|
||||||
create: (context) => FirstTimeUserHomeBloc(
|
create: (context) => FirstTimeUserHomeBloc(
|
||||||
FirstTimeUserHomeRepository(),
|
FirstTimeUserHomeRepository(),
|
||||||
@@ -81,8 +94,13 @@ class MyApp extends StatelessWidget {
|
|||||||
repository: MyPostCardsRepository(),
|
repository: MyPostCardsRepository(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) => GetItineraryBloc(),
|
||||||
|
child: MagicItineraryView(),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
scaffoldMessengerKey: GlobalKeys.scaffoldMessengerKey,
|
||||||
onGenerateRoute: _appRouter.onGenerateRoute,
|
onGenerateRoute: _appRouter.onGenerateRoute,
|
||||||
initialRoute: RouteConstants.splash,
|
initialRoute: RouteConstants.splash,
|
||||||
debugShowCheckedModeBanner: false,
|
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_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:google_fonts/google_fonts.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 '../../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';
|
import '../widgets/pass_widget.dart';
|
||||||
|
|
||||||
class MyPassesView extends StatelessWidget {
|
class MyPassesView extends StatefulWidget {
|
||||||
const MyPassesView({super.key});
|
const MyPassesView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<MyPassesView> createState() => _MyPassesViewState();
|
||||||
return BlocBuilder<MyPassBloc, MyPassState>(
|
}
|
||||||
builder: (context, state) {
|
|
||||||
if (state is MyPassLoading) {
|
class _MyPassesViewState extends State<MyPassesView> {
|
||||||
return const Center(child: CircularProgressIndicator());
|
String selectedCardMode = "";
|
||||||
} else if (state is MyPassEmpty) {
|
String selectedSort = "";
|
||||||
return _noPassView(context);
|
|
||||||
} else if (state is MyPassLoaded) {
|
@override
|
||||||
return _passListView(state.passes);
|
void initState() {
|
||||||
}
|
super.initState();
|
||||||
return const SizedBox.shrink();
|
// 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) {
|
void _showSortBottomSheet() {
|
||||||
return Padding(
|
showModalBottomSheet(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 30.h),
|
context: context,
|
||||||
child: Column(
|
shape: RoundedRectangleBorder(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
|
||||||
children: [
|
),
|
||||||
Image.asset(
|
builder: (context) {
|
||||||
'assets/images/no_pass.png', // your woman sitting image
|
return Container(
|
||||||
height: 180.h,
|
padding: EdgeInsets.all(16.w),
|
||||||
),
|
child: Column(
|
||||||
SizedBox(height: 20.h),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Text(
|
children: [
|
||||||
"You Don’t have a Pass Yet! 😕",
|
ListTile(
|
||||||
style: GoogleFonts.poppins(
|
title: Text(
|
||||||
fontSize: 16.sp,
|
"All",
|
||||||
fontWeight: FontWeight.w600,
|
style: GoogleFonts.poppins(fontSize: 14.sp),
|
||||||
color: Colors.black,
|
),
|
||||||
),
|
onTap: () {
|
||||||
textAlign: TextAlign.center,
|
setState(() {
|
||||||
),
|
selectedSort = "";
|
||||||
SizedBox(height: 8.h),
|
});
|
||||||
Text(
|
Navigator.pop(context);
|
||||||
"Get a pass and get offers and discounts and\nmore on your trip to your favourite city",
|
context.read<MyPassesBloc>().add(FetchMyPasses(
|
||||||
style: GoogleFonts.poppins(fontSize: 12.sp, color: Colors.black54),
|
cardMode: selectedCardMode,
|
||||||
textAlign: TextAlign.center,
|
sort: "",
|
||||||
),
|
));
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
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(
|
child: Text(
|
||||||
"Buy a Pass",
|
state.message,
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(fontSize: 14.sp, color: Colors.red),
|
||||||
color: Colors.white,
|
),
|
||||||
fontSize: 14.sp,
|
);
|
||||||
fontWeight: FontWeight.w600,
|
}
|
||||||
|
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) {
|
Widget _noPassView(BuildContext context) {
|
||||||
return Scaffold(
|
return Padding(
|
||||||
backgroundColor: Colors.white,
|
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||||
body: SafeArea(
|
child: Column(
|
||||||
child: SingleChildScrollView(
|
children: [
|
||||||
padding: EdgeInsets.all(16.0),
|
SizedBox(height: 60.h),
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
/// Illustration Image
|
||||||
children: [
|
Center(
|
||||||
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,),
|
child: Image.asset(
|
||||||
SizedBox(height: 10.h),
|
"assets/images/no_itinerary.png",
|
||||||
Row(
|
height: 260.h,
|
||||||
children: [
|
fit: BoxFit.contain,
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
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/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import '../models/my_passes_model.dart';
|
||||||
|
|
||||||
class PassTicketCard extends StatelessWidget {
|
class PassTicketCard extends StatelessWidget {
|
||||||
final dynamic pass;
|
final MyPassData pass;
|
||||||
|
|
||||||
const PassTicketCard({super.key, required this.pass});
|
const PassTicketCard({super.key, required this.pass});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Dimensions tuned to your screenshot
|
|
||||||
final double cardWidth = MediaQuery.of(context).size.width - 32.w;
|
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 bottomSectionHeight = 50.h;
|
||||||
final double cardHeight = topSectionHeight + bottomSectionHeight;
|
final double cardHeight = topSectionHeight + bottomSectionHeight;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: cardWidth,
|
width: cardWidth,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
// paints white background, border, corner radius, side cuts, shadow, and divider dots
|
|
||||||
painter: _TicketBackgroundPainter(
|
painter: _TicketBackgroundPainter(
|
||||||
cornerRadius: 16.r,
|
cornerRadius: 16.r,
|
||||||
notchRadius: 9.r,
|
notchRadius: 9.r,
|
||||||
@@ -27,7 +26,6 @@ class PassTicketCard extends StatelessWidget {
|
|||||||
shadowColor: Colors.black.withOpacity(0.08),
|
shadowColor: Colors.black.withOpacity(0.08),
|
||||||
),
|
),
|
||||||
child: ClipPath(
|
child: ClipPath(
|
||||||
// actual clipping so child content never bleeds outside the shape
|
|
||||||
clipper: _TicketClipper(
|
clipper: _TicketClipper(
|
||||||
cornerRadius: 16.r,
|
cornerRadius: 16.r,
|
||||||
notchRadius: 9.r,
|
notchRadius: 9.r,
|
||||||
@@ -37,32 +35,36 @@ class PassTicketCard extends StatelessWidget {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// ---------- TOP SECTION ----------
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: topSectionHeight - 12.h, // keep space for the dots line
|
height: topSectionHeight - 12.h,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// thumbnail
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10.r),
|
borderRadius: BorderRadius.circular(10.r),
|
||||||
child: Image.asset(
|
child: Image.network(
|
||||||
pass.imageUrl,
|
pass.city?.bannerImage ?? '',
|
||||||
height: 80.h,
|
height: 80.h,
|
||||||
width: 80.w,
|
width: 80.w,
|
||||||
fit: BoxFit.cover,
|
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),
|
SizedBox(width: 10.w),
|
||||||
|
|
||||||
// details
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (pass.isActive)
|
if (pass.bookingStatus == "active")
|
||||||
Container(
|
Container(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: 8.w, vertical: 3.h),
|
horizontal: 8.w, vertical: 3.h),
|
||||||
@@ -81,7 +83,7 @@ class PassTicketCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(width: 8.w),
|
SizedBox(width: 8.w),
|
||||||
Text(
|
Text(
|
||||||
pass.duration, // "2 Days"
|
"${pass.noOfDays ?? 0} Days",
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
fontSize: 12.sp,
|
fontSize: 12.sp,
|
||||||
@@ -91,7 +93,9 @@ class PassTicketCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(height: 10.h),
|
SizedBox(height: 10.h),
|
||||||
Text(
|
Text(
|
||||||
pass.title,
|
"${(pass.cardMode?.isNotEmpty ?? false)
|
||||||
|
? pass.cardMode![0].toUpperCase() + pass.cardMode!.substring(1)
|
||||||
|
: ''} Card",
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 18.sp,
|
fontSize: 18.sp,
|
||||||
@@ -100,7 +104,7 @@ class PassTicketCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(height: 4.h),
|
SizedBox(height: 4.h),
|
||||||
Text(
|
Text(
|
||||||
"Adults-${pass.adults} • Kids-${pass.kids}",
|
"Adults-${pass.totalAdult ?? 0} • Kids-${pass.totalChild ?? 0}",
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
color: Colors.black54,
|
color: Colors.black54,
|
||||||
fontSize: 11.sp,
|
fontSize: 11.sp,
|
||||||
@@ -109,8 +113,6 @@ class PassTicketCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// QR chip
|
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 20.r,
|
radius: 20.r,
|
||||||
backgroundColor: Color(0xffFEE7E7),
|
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),
|
SizedBox(height: 15.h),
|
||||||
|
|
||||||
// ---------- BOTTOM SECTION ----------
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 4.w),
|
padding: EdgeInsets.symmetric(horizontal: 4.w),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Valid Till: ${pass.validity}",
|
"Valid Till: ${pass.validUpto ?? ''}",
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
fontSize: 11.sp,
|
fontSize: 11.sp,
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
fontWeight: FontWeight.w400
|
fontWeight: FontWeight.w400),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
pass.city, // "Melbourne"
|
pass.city?.name ?? '',
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
fontSize: 13.sp,
|
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> {
|
class _TicketClipper extends CustomClipper<Path> {
|
||||||
final double cornerRadius;
|
final double cornerRadius;
|
||||||
final double notchRadius;
|
final double notchRadius;
|
||||||
@@ -180,10 +176,11 @@ class _TicketClipper extends CustomClipper<Path> {
|
|||||||
));
|
));
|
||||||
|
|
||||||
final cuts = Path()
|
final cuts = Path()
|
||||||
..addOval(Rect.fromCircle(center: Offset(0, dividerY), radius: notchRadius))
|
..addOval(Rect.fromCircle(
|
||||||
..addOval(Rect.fromCircle(center: Offset(size.width, dividerY), radius: notchRadius));
|
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);
|
return Path.combine(PathOperation.difference, rrectPath, cuts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +191,6 @@ class _TicketClipper extends CustomClipper<Path> {
|
|||||||
dividerY != old.dividerY;
|
dividerY != old.dividerY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Paints fill, border, shadow and the dotted perforation line
|
|
||||||
class _TicketBackgroundPainter extends CustomPainter {
|
class _TicketBackgroundPainter extends CustomPainter {
|
||||||
final double cornerRadius;
|
final double cornerRadius;
|
||||||
final double notchRadius;
|
final double notchRadius;
|
||||||
@@ -224,35 +219,30 @@ class _TicketBackgroundPainter extends CustomPainter {
|
|||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final path = _ticketPath(size);
|
final path = _ticketPath(size);
|
||||||
|
|
||||||
// Realistic layered shadow
|
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.translate(0, 2); // tiny downward offset for depth
|
canvas.translate(0, 2);
|
||||||
final shadowPaint = Paint()
|
final shadowPaint = Paint()
|
||||||
..color = Colors.black.withOpacity(0.10)
|
..color = Colors.black.withOpacity(0.10)
|
||||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
|
||||||
canvas.drawPath(path, shadowPaint);
|
canvas.drawPath(path, shadowPaint);
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
|
|
||||||
// Subtle ambient shadow (light spread around)
|
|
||||||
final ambientShadowPaint = Paint()
|
final ambientShadowPaint = Paint()
|
||||||
..color = Colors.black.withOpacity(0.04)
|
..color = Colors.black.withOpacity(0.04)
|
||||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
|
||||||
canvas.drawPath(path, ambientShadowPaint);
|
canvas.drawPath(path, ambientShadowPaint);
|
||||||
|
|
||||||
// Fill background
|
|
||||||
final fillPaint = Paint()
|
final fillPaint = Paint()
|
||||||
..style = PaintingStyle.fill
|
..style = PaintingStyle.fill
|
||||||
..color = const Color(0xffFFFBFB);
|
..color = const Color(0xffFFFBFB);
|
||||||
canvas.drawPath(path, fillPaint);
|
canvas.drawPath(path, fillPaint);
|
||||||
|
|
||||||
// Border stroke
|
|
||||||
final strokePaint = Paint()
|
final strokePaint = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 0.8
|
..strokeWidth = 0.8
|
||||||
..color = const Color(0xffE5E5E5);
|
..color = const Color(0xffE5E5E5);
|
||||||
canvas.drawPath(path, strokePaint);
|
canvas.drawPath(path, strokePaint);
|
||||||
|
|
||||||
// 🔹 Dotted perforation line
|
|
||||||
final dashPaint = Paint()
|
final dashPaint = Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1
|
..strokeWidth = 1
|
||||||
@@ -282,4 +272,4 @@ class _TicketBackgroundPainter extends CustomPainter {
|
|||||||
borderColor != oldDelegate.borderColor ||
|
borderColor != oldDelegate.borderColor ||
|
||||||
shadowColor != oldDelegate.shadowColor;
|
shadowColor != oldDelegate.shadowColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,21 +5,30 @@ class ApiUrls {
|
|||||||
static const baseUrl = "https://uatapi.citycard.betadelivery.com";// Production Lvl API
|
static const baseUrl = "https://uatapi.citycard.betadelivery.com";// Production Lvl API
|
||||||
|
|
||||||
static const refreshToken = "$baseUrl/auth/refresh";
|
static const refreshToken = "$baseUrl/auth/refresh";
|
||||||
|
|
||||||
static const cityList = "$baseUrl/mobile/city_list";
|
static const cityList = "$baseUrl/mobile/city_list";
|
||||||
// static const upcomingCityList = "$baseUrl/mobile/upcoming_cities";
|
// static const upcomingCityList = "$baseUrl/mobile/upcoming_cities";
|
||||||
static const searchCityList = "$baseUrl/mobile/city-selection";
|
static const searchCityList = "$baseUrl/mobile/city-selection";
|
||||||
static const attractionsList = "$baseUrl/mobile/list/all";
|
static const attractionsList = "$baseUrl/mobile/list/all";
|
||||||
|
static const passAttractionsList = "$baseUrl/mobile/passes/mobile/list";
|
||||||
static const attractionDetails = "$baseUrl/mobile/list";
|
static const attractionDetails = "$baseUrl/mobile/list";
|
||||||
static const home = "$baseUrl/mobile";
|
static const home = "$baseUrl/mobile";
|
||||||
static const faqPrivacyTerms = "$baseUrl/mobile/user/cms-data";
|
static const faqPrivacyTerms = "$baseUrl/mobile/user/cms-data";
|
||||||
static const userProfile = "$baseUrl/mobile/user";
|
static const userProfile = "$baseUrl/mobile/user";
|
||||||
static const offers = "$baseUrl/mobile/list/offers";
|
static const offers = "$baseUrl/mobile/list/offers";
|
||||||
|
static const passOffers = "$baseUrl/mobile/passes/mobile/list/offers";
|
||||||
static const buyAPass = "$baseUrl/mobile/pass";
|
static const buyAPass = "$baseUrl/mobile/pass";
|
||||||
static const offersDetails = "$baseUrl/mobile/list/offers";
|
static const offersDetails = "$baseUrl/mobile/list/offers";
|
||||||
static const myPostCards = "$baseUrl/mobile/postcards/all";
|
static const myPostCards = "$baseUrl/mobile/postcards/all";
|
||||||
static const coupons = "$baseUrl/mobile/passes/dropdown/card";
|
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
|
//Post Apis
|
||||||
static const createAccount = "$baseUrl/mobile/user/register";
|
static const createAccount = "$baseUrl/mobile/user/register";
|
||||||
@@ -28,4 +37,4 @@ class ApiUrls {
|
|||||||
static const submitTicket = "$baseUrl/mobile/user/support";
|
static const submitTicket = "$baseUrl/mobile/user/support";
|
||||||
static const createPostCard = "$baseUrl/mobile/postcards";
|
static const createPostCard = "$baseUrl/mobile/postcards";
|
||||||
static const addToCartPasses = "$baseUrl/mobile/passes/add-to-cart";
|
static const addToCartPasses = "$baseUrl/mobile/passes/add-to-cart";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../localPreference/local_preference.dart';
|
import '../localPreference/local_preference.dart';
|
||||||
@@ -34,14 +36,17 @@ class NetworkApiService {
|
|||||||
const maxRetries = 2;
|
const maxRetries = 2;
|
||||||
final currentRetry = options.extra['retry'] as int? ?? 0;
|
final currentRetry = options.extra['retry'] as int? ?? 0;
|
||||||
|
|
||||||
final shouldRetry = currentRetry < maxRetries &&
|
final shouldRetry =
|
||||||
|
currentRetry < maxRetries &&
|
||||||
(err.type == DioExceptionType.connectionTimeout ||
|
(err.type == DioExceptionType.connectionTimeout ||
|
||||||
err.type == DioExceptionType.sendTimeout ||
|
err.type == DioExceptionType.sendTimeout ||
|
||||||
err.type == DioExceptionType.receiveTimeout);
|
err.type == DioExceptionType.receiveTimeout);
|
||||||
|
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('🔁 Retrying request (${currentRetry + 1}) => ${options.uri}');
|
print(
|
||||||
|
'🔁 Retrying request (${currentRetry + 1}) => ${options.uri}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
options.extra['retry'] = currentRetry + 1;
|
options.extra['retry'] = currentRetry + 1;
|
||||||
@@ -65,6 +70,7 @@ class NetworkApiService {
|
|||||||
QueuedInterceptorsWrapper(
|
QueuedInterceptorsWrapper(
|
||||||
onRequest: (options, handler) async {
|
onRequest: (options, handler) async {
|
||||||
final token = await LocalPreference.getAccessToken();
|
final token = await LocalPreference.getAccessToken();
|
||||||
|
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
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 =================
|
// ================= REFRESH TOKEN =================
|
||||||
Future<bool> _refreshToken() async {
|
Future<bool> _refreshToken() async {
|
||||||
try {
|
try {
|
||||||
@@ -188,9 +215,7 @@ class NetworkApiService {
|
|||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
ApiUrls.refreshToken,
|
ApiUrls.refreshToken,
|
||||||
data: {"refreshToken": refreshToken},
|
data: {"refreshToken": refreshToken},
|
||||||
options: Options(
|
options: Options(headers: {'Authorization': null}),
|
||||||
headers: {'Authorization': null},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await LocalPreference.setAccessToken(response.data['accessToken']);
|
await LocalPreference.setAccessToken(response.data['accessToken']);
|
||||||
|
|
||||||
@@ -221,7 +246,7 @@ class NetworkApiService {
|
|||||||
case DioExceptionType.badCertificate:
|
case DioExceptionType.badCertificate:
|
||||||
return "Bad certificate.";
|
return "Bad certificate.";
|
||||||
case DioExceptionType.badResponse:
|
case DioExceptionType.badResponse:
|
||||||
// 🔥 FIXED: Safely handle different response data types
|
// 🔥 FIXED: Safely handle different response data types
|
||||||
try {
|
try {
|
||||||
final responseData = error.response?.data;
|
final responseData = error.response?.data;
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class OffersDetailsView extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (_) => OfferDetailsBloc(
|
create: (_) => OfferDetailsBloc(
|
||||||
repository: OffersDetailsRepository(), // ← Create directly
|
repository: OffersDetailsRepository(), // ✅ Create directly
|
||||||
)..add(FetchOfferDetailsEvent(offerId: offerId)),
|
)..add(FetchOfferDetailsEvent(offerId: offerId)),
|
||||||
child: const _OffersDetailsContent(),
|
child: const _OffersDetailsContent(),
|
||||||
);
|
);
|
||||||
@@ -106,12 +106,16 @@ class _OffersDetailsContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 8.w),
|
SizedBox(width: 8.w),
|
||||||
Text(
|
Expanded(
|
||||||
offer.partnerName,
|
child: Text(
|
||||||
style: TextStyle(
|
offer.partnerName,
|
||||||
fontSize: 14.sp,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontSize: 14.sp,
|
||||||
color: Colors.white,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -125,6 +129,7 @@ class _OffersDetailsContent extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
bottom: 31.h,
|
bottom: 31.h,
|
||||||
left: 12.w,
|
left: 12.w,
|
||||||
|
right: 60.w,
|
||||||
child: Text(
|
child: Text(
|
||||||
offer.partnerName,
|
offer.partnerName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -133,6 +138,8 @@ class _OffersDetailsContent extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
height: 1.2,
|
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});
|
||||||
|
}
|
||||||