Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfb9c74ca | ||
| 0abdd2b796 | |||
| dd1991da09 | |||
|
|
48fd7037ea | ||
|
|
40f0ed3a52 | ||
|
|
b08e2699e9 | ||
|
|
53264619a8 | ||
|
|
5d08e07de3 | ||
|
|
68c3f28d76 | ||
|
|
3a08830cce | ||
|
|
0c663bdec7 | ||
|
|
e91d24becc | ||
|
|
09726eb4e6 | ||
|
|
10eae3577f | ||
|
|
460f553aee | ||
|
|
a7548ccebd | ||
|
|
c2ffc9d9a7 | ||
|
|
082bb9b74a | ||
|
|
fa4f78bceb | ||
|
|
0434b16bde | ||
|
|
1cb344738e | ||
|
|
f5782f6da1 | ||
|
|
bbb96512d1 | ||
|
|
a55510a482 | ||
|
|
d3abf4053a | ||
|
|
aac65c57be | ||
|
|
c62c725410 |
@@ -12,7 +12,7 @@ A few resources to get you started if this is your first Flutter project:
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
[online documentation](https://docs.flutter.dev/),which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
|
||||
<h1>Figma Link</h1>
|
||||
|
||||
@@ -35,10 +35,16 @@ android {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
}
|
||||
15
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
|
||||
# Keep Stripe Push Provisioning classes
|
||||
-keep class com.stripe.android.pushProvisioning.** { *; }
|
||||
-dontwarn com.stripe.android.pushProvisioning.**
|
||||
|
||||
# Keep Stripe SDK
|
||||
-keep class com.stripe.android.** { *; }
|
||||
-dontwarn com.stripe.android.**
|
||||
|
||||
# Keep React Native Stripe SDK
|
||||
-keep class com.reactnativestripesdk.** { *; }
|
||||
-dontwarn com.reactnativestripesdk.**
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.citycards_customer.citycards_customer
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
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/empty_postcard_drafts.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
assets/images/empty_postcard_orders.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/images/guest_illustration.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
assets/images/no_itinerary.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/images/not_login.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/postcard_stamp_logo.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
assets/images/unlimited_card_details.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
234
lib/StripePayment/bloc/stripe_payment_bloc.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_stripe/flutter_stripe.dart';
|
||||
|
||||
import '../repository/stripe_service.dart';
|
||||
import 'stripe_payment_event.dart';
|
||||
import 'stripe_payment_state.dart';
|
||||
|
||||
class StripePaymentBloc extends Bloc<StripePaymentEvent, StripePaymentState> {
|
||||
final StripeService _stripeService;
|
||||
|
||||
// 🔒 Flag to prevent re-initialization after success
|
||||
bool _paymentCompleted = false;
|
||||
|
||||
StripePaymentBloc({
|
||||
StripeService? stripeService,
|
||||
}) : _stripeService = stripeService ?? StripeService(),
|
||||
super(const StripePaymentInitial()) {
|
||||
on<InitiatePayment>(_onInitiatePayment);
|
||||
on<InitiatePaymentWithClientSecret>(_onInitiatePaymentWithClientSecret);
|
||||
on<CancelPaymentEvent>(_onCancelPayment);
|
||||
on<ResetPaymentState>(_onResetPaymentState);
|
||||
on<RetryPaymentEvent>(_onRetryPayment);
|
||||
}
|
||||
|
||||
Future<void> _onInitiatePayment(
|
||||
InitiatePayment event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 🛑 Prevent re-initialization if payment already completed
|
||||
if (_paymentCompleted) {
|
||||
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Creating payment intent...',
|
||||
));
|
||||
|
||||
/// Stripe expects smallest currency unit
|
||||
/// USD → cents, INR → paise
|
||||
final int stripeAmount = (event.amount * 100).toInt();
|
||||
|
||||
// 1️⃣ Create PaymentIntent from backend
|
||||
final clientSecret = await _stripeService.createPaymentIntent(
|
||||
amount: stripeAmount,
|
||||
currency: event.currency,
|
||||
);
|
||||
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Initializing payment sheet...',
|
||||
));
|
||||
|
||||
// 2️⃣ Init Payment Sheet
|
||||
await Stripe.instance.initPaymentSheet(
|
||||
paymentSheetParameters: SetupPaymentSheetParameters(
|
||||
paymentIntentClientSecret: clientSecret,
|
||||
merchantDisplayName: "CityCards",
|
||||
style: ThemeMode.light,
|
||||
),
|
||||
);
|
||||
|
||||
emit(const StripePaymentSheetReady());
|
||||
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Processing payment...',
|
||||
));
|
||||
|
||||
// 3️⃣ Show Payment Sheet
|
||||
await Stripe.instance.presentPaymentSheet();
|
||||
|
||||
// ✅ SUCCESS - Mark as completed
|
||||
_paymentCompleted = true;
|
||||
emit(const StripePaymentSuccess());
|
||||
} on StripeException catch (e) {
|
||||
_handleStripeException(e, emit);
|
||||
} catch (e) {
|
||||
emit(StripePaymentFailure(
|
||||
error: 'An unexpected error occurred: ${e.toString()}',
|
||||
isRetryable: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment with clientSecret directly from backend
|
||||
Future<void> _onInitiatePaymentWithClientSecret(
|
||||
InitiatePaymentWithClientSecret event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 🛑 Prevent re-initialization if payment already completed
|
||||
if (_paymentCompleted) {
|
||||
debugPrint('⚠️ Payment already completed. Ignoring re-initialization.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Initializing payment...',
|
||||
));
|
||||
|
||||
// 1️⃣ Init Payment Sheet with clientSecret from backend
|
||||
await Stripe.instance.initPaymentSheet(
|
||||
paymentSheetParameters: SetupPaymentSheetParameters(
|
||||
paymentIntentClientSecret: event.clientSecret,
|
||||
merchantDisplayName: "CityCards",
|
||||
style: ThemeMode.light,
|
||||
),
|
||||
);
|
||||
|
||||
emit(const StripePaymentSheetReady());
|
||||
|
||||
emit(const StripePaymentLoading(
|
||||
message: 'Processing payment...',
|
||||
));
|
||||
|
||||
// 2️⃣ Show Payment Sheet
|
||||
await Stripe.instance.presentPaymentSheet();
|
||||
|
||||
// ✅ SUCCESS - Mark as completed
|
||||
_paymentCompleted = true;
|
||||
emit(const StripePaymentSuccess());
|
||||
} on StripeException catch (e) {
|
||||
_handleStripeException(e, emit);
|
||||
} catch (e) {
|
||||
emit(StripePaymentFailure(
|
||||
error: 'An unexpected error occurred: ${e.toString()}',
|
||||
isRetryable: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment cancellation
|
||||
void _onCancelPayment(
|
||||
CancelPaymentEvent event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
// Only emit cancelled if not already completed
|
||||
if (!_paymentCompleted) {
|
||||
emit(const StripePaymentCancelled(
|
||||
message: 'Payment cancelled by user',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle payment retry
|
||||
Future<void> _onRetryPayment(
|
||||
RetryPaymentEvent event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 🔄 Reset completion flag for retry
|
||||
_paymentCompleted = false;
|
||||
|
||||
// Reset state first
|
||||
emit(const StripePaymentInitial());
|
||||
|
||||
// Then initiate payment again
|
||||
add(InitiatePaymentWithClientSecret(
|
||||
clientSecret: event.clientSecret,
|
||||
));
|
||||
}
|
||||
|
||||
/// Reset payment state back to initial
|
||||
void _onResetPaymentState(
|
||||
ResetPaymentState event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
// 🔄 Reset completion flag
|
||||
_paymentCompleted = false;
|
||||
emit(const StripePaymentInitial());
|
||||
}
|
||||
|
||||
/// Centralized Stripe exception handling
|
||||
void _handleStripeException(
|
||||
StripeException e,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) {
|
||||
final errorCode = e.error.code;
|
||||
final errorMessage = e.error.localizedMessage ?? 'Payment failed';
|
||||
|
||||
// Handle cancellation separately
|
||||
if (errorCode == FailureCode.Canceled) {
|
||||
emit(StripePaymentCancelled(
|
||||
message: errorMessage,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different error types
|
||||
switch (errorCode) {
|
||||
case FailureCode.Failed:
|
||||
emit(StripePaymentFailure(
|
||||
error: errorMessage,
|
||||
errorCode: errorCode.toString(),
|
||||
isRetryable: true,
|
||||
));
|
||||
break;
|
||||
|
||||
case FailureCode.Timeout:
|
||||
emit(const StripePaymentFailure(
|
||||
error: 'Payment timed out. Please try again.',
|
||||
errorCode: 'timeout',
|
||||
isRetryable: true,
|
||||
));
|
||||
break;
|
||||
|
||||
default:
|
||||
emit(StripePaymentFailure(
|
||||
error: errorMessage,
|
||||
errorCode: errorCode?.toString(),
|
||||
isRetryable: _isRetryableError(errorCode),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if an error is retryable
|
||||
bool _isRetryableError(FailureCode? errorCode) {
|
||||
if (errorCode == null) return true;
|
||||
|
||||
// Non-retryable errors
|
||||
const nonRetryableErrors = [
|
||||
// Add specific non-retryable error codes here if needed
|
||||
];
|
||||
|
||||
return !nonRetryableErrors.contains(errorCode);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
// Reset flag on bloc disposal
|
||||
_paymentCompleted = false;
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
55
lib/StripePayment/bloc/stripe_payment_event.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class StripePaymentEvent extends Equatable {
|
||||
const StripePaymentEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class InitiatePayment extends StripePaymentEvent {
|
||||
final double amount;
|
||||
final String currency;
|
||||
|
||||
const InitiatePayment({
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [amount, currency];
|
||||
}
|
||||
|
||||
/// Event to initiate payment with clientSecret from backend
|
||||
class InitiatePaymentWithClientSecret extends StripePaymentEvent {
|
||||
final String clientSecret;
|
||||
|
||||
const InitiatePaymentWithClientSecret({
|
||||
required this.clientSecret,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [clientSecret];
|
||||
}
|
||||
|
||||
/// Event to cancel ongoing payment
|
||||
class CancelPaymentEvent extends StripePaymentEvent {
|
||||
const CancelPaymentEvent();
|
||||
}
|
||||
|
||||
/// Event to reset payment state back to initial
|
||||
class ResetPaymentState extends StripePaymentEvent {
|
||||
const ResetPaymentState();
|
||||
}
|
||||
|
||||
/// Event to retry failed payment
|
||||
class RetryPaymentEvent extends StripePaymentEvent {
|
||||
final String clientSecret;
|
||||
|
||||
const RetryPaymentEvent({
|
||||
required this.clientSecret,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [clientSecret];
|
||||
}
|
||||
96
lib/StripePayment/bloc/stripe_payment_state.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class StripePaymentState extends Equatable {
|
||||
const StripePaymentState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Initial state before any payment action
|
||||
class StripePaymentInitial extends StripePaymentState {
|
||||
const StripePaymentInitial();
|
||||
}
|
||||
|
||||
/// Payment is being processed
|
||||
class StripePaymentLoading extends StripePaymentState {
|
||||
final String? message;
|
||||
|
||||
const StripePaymentLoading({
|
||||
this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment sheet is initialized and ready to be presented
|
||||
class StripePaymentSheetReady extends StripePaymentState {
|
||||
const StripePaymentSheetReady();
|
||||
}
|
||||
|
||||
/// Payment was successful
|
||||
class StripePaymentSuccess extends StripePaymentState {
|
||||
final String message;
|
||||
final String? paymentIntentId;
|
||||
|
||||
const StripePaymentSuccess({
|
||||
this.message = 'Payment Successful',
|
||||
this.paymentIntentId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, paymentIntentId];
|
||||
}
|
||||
|
||||
/// Payment failed
|
||||
class StripePaymentFailure extends StripePaymentState {
|
||||
final String error;
|
||||
final String? errorCode;
|
||||
final bool isRetryable;
|
||||
|
||||
const StripePaymentFailure({
|
||||
required this.error,
|
||||
this.errorCode,
|
||||
this.isRetryable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [error, errorCode, isRetryable];
|
||||
}
|
||||
|
||||
/// Payment was cancelled by user
|
||||
class StripePaymentCancelled extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentCancelled({
|
||||
this.message = 'Payment Cancelled',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment requires additional authentication (3D Secure, etc.)
|
||||
class StripePaymentRequiresAction extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentRequiresAction({
|
||||
this.message = 'Additional authentication required',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// Payment is processing on the backend
|
||||
class StripePaymentProcessing extends StripePaymentState {
|
||||
final String message;
|
||||
|
||||
const StripePaymentProcessing({
|
||||
this.message = 'Payment is being processed...',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
97
lib/StripePayment/repository/stripe_service.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class StripeService {
|
||||
final Dio _dio = Dio(
|
||||
BaseOptions(
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ⚠️ TEMPORARY FALLBACK - Use secret key directly
|
||||
// TODO: Remove this and use backend when ready!
|
||||
final String _stripeSecretKey = ''; // ← ADD YOUR SECRET KEY
|
||||
|
||||
Future<String> createPaymentIntent({
|
||||
required int amount,
|
||||
required String currency,
|
||||
}) async {
|
||||
try {
|
||||
// 🔥 DIRECT STRIPE API CALL (Temporary fallback)
|
||||
final response = await _dio.post(
|
||||
'https://api.stripe.com/v1/payment_intents',
|
||||
data: {
|
||||
'amount': amount.toString(),
|
||||
'currency': currency,
|
||||
'automatic_payment_methods[enabled]': 'true',
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': 'Bearer $_stripeSecretKey',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data == null || response.data['client_secret'] == null) {
|
||||
throw Exception('Invalid response from Stripe');
|
||||
}
|
||||
|
||||
return response.data['client_secret'];
|
||||
} on DioException catch (e) {
|
||||
if (e.response != null) {
|
||||
print('Stripe API Error: ${e.response?.data}');
|
||||
throw Exception('Stripe error: ${e.response?.data['error']?['message'] ?? e.message}');
|
||||
}
|
||||
throw Exception('Network error: ${e.message}');
|
||||
} catch (e) {
|
||||
print('Payment Intent Error: $e');
|
||||
throw Exception('Failed to create payment intent: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
🔒 PRODUCTION VERSION (Use this when backend is ready):
|
||||
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class StripeService {
|
||||
final Dio _dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: ApiUrls.baseUrl,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Future<String> createPaymentIntent({
|
||||
required int amount,
|
||||
required String currency,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
"/create-payment-intent",
|
||||
data: {
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data == null || response.data['clientSecret'] == null) {
|
||||
throw Exception('Invalid response from server');
|
||||
}
|
||||
|
||||
return response.data['clientSecret'];
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Network error: ${e.message}');
|
||||
} catch (e) {
|
||||
throw Exception('Failed to create payment intent: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
475
lib/StripePayment/view/stripe_payment.dart
Normal file
@@ -0,0 +1,475 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../bloc/stripe_payment_bloc.dart';
|
||||
import '../bloc/stripe_payment_event.dart';
|
||||
import '../bloc/stripe_payment_state.dart';
|
||||
import '../repository/stripe_service.dart';
|
||||
|
||||
/// 🎯 Reusable Stripe Payment Screen
|
||||
///
|
||||
/// This widget handles Stripe payment flow and can be used across different features
|
||||
/// like postcards, subscriptions, bookings, etc.
|
||||
class StripePaymentScreen extends StatelessWidget {
|
||||
/// Client secret from your backend payment intent
|
||||
final String clientSecret;
|
||||
|
||||
/// Amount to display (optional)
|
||||
final double? amount;
|
||||
|
||||
/// Currency symbol (default: \$)
|
||||
final String currencySymbol;
|
||||
|
||||
/// Custom title for the payment screen
|
||||
final String? title;
|
||||
|
||||
/// Custom loading message
|
||||
final String loadingMessage;
|
||||
|
||||
/// Custom success message
|
||||
final String successMessage;
|
||||
|
||||
/// Custom failure message prefix
|
||||
final String failureMessage;
|
||||
|
||||
/// Callback when payment succeeds
|
||||
final VoidCallback? onPaymentSuccess;
|
||||
|
||||
/// Callback when payment fails
|
||||
final void Function(String error)? onPaymentFailure;
|
||||
|
||||
/// Callback when payment is cancelled
|
||||
final VoidCallback? onPaymentCancelled;
|
||||
|
||||
/// Primary color for the UI
|
||||
final Color primaryColor;
|
||||
|
||||
/// Success icon color
|
||||
final Color successColor;
|
||||
|
||||
/// Error icon color
|
||||
final Color errorColor;
|
||||
|
||||
/// Custom height ratio (0.0 to 1.0)
|
||||
final double heightRatio;
|
||||
|
||||
/// Whether to show close button during loading
|
||||
final bool showCloseButtonDuringLoading;
|
||||
|
||||
/// Custom widget to show above the status (optional)
|
||||
final Widget? headerWidget;
|
||||
|
||||
/// Custom widget to show below the status (optional)
|
||||
final Widget? footerWidget;
|
||||
|
||||
const StripePaymentScreen({
|
||||
super.key,
|
||||
required this.clientSecret,
|
||||
this.amount,
|
||||
this.currencySymbol = '\$',
|
||||
this.title,
|
||||
this.loadingMessage = 'Processing payment...',
|
||||
this.successMessage = 'Payment Successful!',
|
||||
this.failureMessage = 'Payment Failed',
|
||||
this.onPaymentSuccess,
|
||||
this.onPaymentFailure,
|
||||
this.onPaymentCancelled,
|
||||
this.primaryColor = const Color(0xFFF95F62),
|
||||
this.successColor = Colors.green,
|
||||
this.errorColor = Colors.red,
|
||||
this.heightRatio = 0.5,
|
||||
this.showCloseButtonDuringLoading = false,
|
||||
this.headerWidget,
|
||||
this.footerWidget,
|
||||
});
|
||||
|
||||
/// 🚀 Static method to show as bottom sheet
|
||||
static Future<bool?> showAsBottomSheet({
|
||||
required BuildContext context,
|
||||
required String clientSecret,
|
||||
double? amount,
|
||||
String currencySymbol = '\$',
|
||||
String? title,
|
||||
String loadingMessage = 'Processing payment...',
|
||||
String successMessage = 'Payment Successful!',
|
||||
String failureMessage = 'Payment Failed',
|
||||
VoidCallback? onPaymentSuccess,
|
||||
void Function(String error)? onPaymentFailure,
|
||||
VoidCallback? onPaymentCancelled,
|
||||
Color primaryColor = const Color(0xFFF95F62),
|
||||
Color successColor = Colors.green,
|
||||
Color errorColor = Colors.red,
|
||||
double heightRatio = 0.5,
|
||||
bool isDismissible = false,
|
||||
bool enableDrag = false,
|
||||
bool showCloseButtonDuringLoading = false,
|
||||
Widget? headerWidget,
|
||||
Widget? footerWidget,
|
||||
}) async {
|
||||
return await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (bottomSheetContext) {
|
||||
return BlocProvider(
|
||||
create: (_) => StripePaymentBloc(stripeService: StripeService())
|
||||
..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)),
|
||||
child: StripePaymentScreen(
|
||||
clientSecret: clientSecret,
|
||||
amount: amount,
|
||||
currencySymbol: currencySymbol,
|
||||
title: title,
|
||||
loadingMessage: loadingMessage,
|
||||
successMessage: successMessage,
|
||||
failureMessage: failureMessage,
|
||||
onPaymentSuccess: onPaymentSuccess,
|
||||
onPaymentFailure: onPaymentFailure,
|
||||
onPaymentCancelled: onPaymentCancelled,
|
||||
primaryColor: primaryColor,
|
||||
successColor: successColor,
|
||||
errorColor: errorColor,
|
||||
heightRatio: heightRatio,
|
||||
showCloseButtonDuringLoading: showCloseButtonDuringLoading,
|
||||
headerWidget: headerWidget,
|
||||
footerWidget: footerWidget,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🚀 Static method to show as full screen dialog
|
||||
static Future<bool?> showAsDialog({
|
||||
required BuildContext context,
|
||||
required String clientSecret,
|
||||
double? amount,
|
||||
String currencySymbol = '\$',
|
||||
String? title,
|
||||
String loadingMessage = 'Processing payment...',
|
||||
String successMessage = 'Payment Successful!',
|
||||
String failureMessage = 'Payment Failed',
|
||||
VoidCallback? onPaymentSuccess,
|
||||
void Function(String error)? onPaymentFailure,
|
||||
VoidCallback? onPaymentCancelled,
|
||||
Color primaryColor = const Color(0xFFF95F62),
|
||||
Color successColor = Colors.green,
|
||||
Color errorColor = Colors.red,
|
||||
bool barrierDismissible = false,
|
||||
bool showCloseButtonDuringLoading = false,
|
||||
Widget? headerWidget,
|
||||
Widget? footerWidget,
|
||||
}) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (dialogContext) {
|
||||
return BlocProvider(
|
||||
create: (_) => StripePaymentBloc(stripeService: StripeService())
|
||||
..add(InitiatePaymentWithClientSecret(clientSecret: clientSecret)),
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: StripePaymentScreen(
|
||||
clientSecret: clientSecret,
|
||||
amount: amount,
|
||||
currencySymbol: currencySymbol,
|
||||
title: title,
|
||||
loadingMessage: loadingMessage,
|
||||
successMessage: successMessage,
|
||||
failureMessage: failureMessage,
|
||||
onPaymentSuccess: onPaymentSuccess,
|
||||
onPaymentFailure: onPaymentFailure,
|
||||
onPaymentCancelled: onPaymentCancelled,
|
||||
primaryColor: primaryColor,
|
||||
successColor: successColor,
|
||||
errorColor: errorColor,
|
||||
heightRatio: 1.0,
|
||||
showCloseButtonDuringLoading: showCloseButtonDuringLoading,
|
||||
headerWidget: headerWidget,
|
||||
footerWidget: footerWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<StripePaymentBloc, StripePaymentState>(
|
||||
// 🔒 CRITICAL: Only listen when state actually changes to prevent duplicate triggers
|
||||
listenWhen: (previous, current) {
|
||||
// Don't re-trigger if both states are the same success state
|
||||
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
|
||||
debugPrint('⚠️ Preventing duplicate success listener');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is StripePaymentSuccess) {
|
||||
debugPrint('✅ Payment Success - Calling callback');
|
||||
// ✅ Call the callback first
|
||||
onPaymentSuccess?.call();
|
||||
// ✅ Then auto-close and return true after 1.5 seconds
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
});
|
||||
} else if (state is StripePaymentFailure) {
|
||||
debugPrint('❌ Payment Failure - ${state.error}');
|
||||
onPaymentFailure?.call(state.error);
|
||||
// Auto-close after 2 seconds on failure
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
});
|
||||
} else if (state is StripePaymentCancelled) {
|
||||
debugPrint('🚫 Payment Cancelled');
|
||||
onPaymentCancelled?.call();
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
},
|
||||
buildWhen: (previous, current) {
|
||||
// 🔒 Prevent unnecessary rebuilds on duplicate success states
|
||||
if (previous is StripePaymentSuccess && current is StripePaymentSuccess) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Container(
|
||||
height: heightRatio == 1.0
|
||||
? MediaQuery.of(context).size.height
|
||||
: MediaQuery.of(context).size.height * heightRatio,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: heightRatio == 1.0
|
||||
? null
|
||||
: const BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main content
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Custom header widget
|
||||
if (headerWidget != null) ...[
|
||||
headerWidget!,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Title
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Amount display
|
||||
if (amount != null) ...[
|
||||
Text(
|
||||
'$currencySymbol${amount!.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontSize: 32.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// Payment status
|
||||
_buildPaymentStatus(context, state),
|
||||
|
||||
// Custom footer widget
|
||||
if (footerWidget != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
footerWidget!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Close button (only show when allowed)
|
||||
if (_shouldShowCloseButton(state))
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
if (state is StripePaymentLoading) {
|
||||
// Cancel payment if loading
|
||||
context
|
||||
.read<StripePaymentBloc>()
|
||||
.add(CancelPaymentEvent());
|
||||
} else {
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Colors.grey[600],
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build payment status widget based on state
|
||||
Widget _buildPaymentStatus(BuildContext context, StripePaymentState state) {
|
||||
if (state is StripePaymentLoading) {
|
||||
return Column(
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
loadingMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentSuccess) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: successColor,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
successMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentFailure) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
color: errorColor,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
failureMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.error,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Retry payment
|
||||
context.read<StripePaymentBloc>().add(
|
||||
RetryPaymentEvent(
|
||||
clientSecret: clientSecret,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry Payment',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is StripePaymentCancelled) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cancel,
|
||||
color: Colors.orange,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Payment Cancelled',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// Determine if close button should be shown
|
||||
bool _shouldShowCloseButton(StripePaymentState state) {
|
||||
if (state is StripePaymentLoading) {
|
||||
return showCloseButtonDuringLoading;
|
||||
}
|
||||
// Show for failure and cancelled states
|
||||
return state is StripePaymentFailure || state is StripePaymentCancelled;
|
||||
}
|
||||
}
|
||||
@@ -2,184 +2,270 @@ import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_textfield.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class AddDetailsView extends StatelessWidget {
|
||||
AddDetailsView({super.key});
|
||||
import '../checkout/bloc/pass_purchase_details_bloc.dart';
|
||||
import '../checkout/bloc/pass_purchase_details_event.dart';
|
||||
import '../checkout/bloc/pass_purchase_details_state.dart';
|
||||
|
||||
class AddDetailsView extends StatefulWidget {
|
||||
final int bookingId;
|
||||
|
||||
const AddDetailsView({super.key, required this.bookingId});
|
||||
|
||||
@override
|
||||
State<AddDetailsView> createState() => _AddDetailsViewState();
|
||||
}
|
||||
|
||||
class _AddDetailsViewState extends State<AddDetailsView> {
|
||||
final TextEditingController firstNameController = TextEditingController();
|
||||
final TextEditingController lastNameController = TextEditingController();
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController addressController = TextEditingController();
|
||||
final TextEditingController cityController = TextEditingController();
|
||||
String? selectedCountry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Column(
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showCart: false,
|
||||
showDivider: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(Icons.arrow_back, size: 24.sp),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
"Add details",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 42.h),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: "Tell us about yourself",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
void dispose() {
|
||||
firstNameController.dispose();
|
||||
lastNameController.dispose();
|
||||
emailController.dispose();
|
||||
phoneController.dispose();
|
||||
cityController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "First Name",
|
||||
hint: "Enter your first name",
|
||||
controller: firstNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Last Name",
|
||||
hint: "Enter your last name",
|
||||
controller: lastNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Email",
|
||||
hint: "Enter your email address",
|
||||
controller: emailController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Phone Number",
|
||||
hint: "Enter your phone number",
|
||||
controller: phoneController,
|
||||
),
|
||||
),
|
||||
void _handleSubmit(BuildContext context, bool isSubmitting) {
|
||||
// If already submitting, do nothing
|
||||
if (isSubmitting) return;
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "City",
|
||||
hint: "Enter the name of your city",
|
||||
controller: phoneController,
|
||||
),
|
||||
),
|
||||
|
||||
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: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
String? selectedCountry;
|
||||
return DropdownButton<String>(
|
||||
value: selectedCountry,
|
||||
isExpanded: true,
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF8E8E8E),
|
||||
),
|
||||
hint: Text(
|
||||
"Select your country",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Color(0xFF8E8E8E),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF2D3134),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedCountry = value;
|
||||
});
|
||||
},
|
||||
items: ["India", "USA", "UK", "Canada"].map((
|
||||
value,
|
||||
) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
|
||||
},
|
||||
label: "Continue",
|
||||
width: double.infinity,
|
||||
),
|
||||
SizedBox(height: 50.h),
|
||||
],
|
||||
),
|
||||
// Validate inputs
|
||||
if (firstNameController.text.isEmpty ||
|
||||
lastNameController.text.isEmpty ||
|
||||
emailController.text.isEmpty ||
|
||||
phoneController.text.isEmpty ||
|
||||
cityController.text.isEmpty ||
|
||||
selectedCountry == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please fill all fields'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit gift details
|
||||
context.read<PurchaseDetailsBloc>().add(
|
||||
SubmitUserDetailsEvent(
|
||||
bookingId: widget.bookingId,
|
||||
isForSelf: false,
|
||||
recipientFirstName: firstNameController.text,
|
||||
recipientLastName: lastNameController.text,
|
||||
recipientEmail: emailController.text,
|
||||
recipientPhone: phoneController.text,
|
||||
city: cityController.text,
|
||||
country: selectedCountry!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => PurchaseDetailsBloc(),
|
||||
child: BlocConsumer<PurchaseDetailsBloc, PurchaseDetailsState>(
|
||||
listener: (context, state) {
|
||||
// Handle API submission success
|
||||
if (state is PurchaseDetailsSubmitted) {
|
||||
// Show success message
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// const SnackBar(
|
||||
// content: Text('Gift details submitted successfully!'),
|
||||
// backgroundColor: Color(0xffF95F62),
|
||||
// ),
|
||||
// );
|
||||
|
||||
// Navigate back
|
||||
Navigator.of(context).pop('success');
|
||||
}
|
||||
|
||||
// Handle API submission error
|
||||
if (state is PurchaseDetailsError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Failed to submit details'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final isSubmitting = state.isSubmittingDetails;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Column(
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showCart: false,
|
||||
showDivider: true,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(Icons.arrow_back, size: 24.sp),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
"Add details",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 42.h),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: "Tell us about the recipient",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "First Name",
|
||||
hint: "Enter recipient's first name",
|
||||
controller: firstNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Last Name",
|
||||
hint: "Enter recipient's last name",
|
||||
controller: lastNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Email",
|
||||
hint: "Enter recipient's email address",
|
||||
controller: emailController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Phone Number",
|
||||
hint: "Enter recipient's phone number",
|
||||
controller: phoneController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "City",
|
||||
hint: "Enter the name of the city",
|
||||
controller: cityController,
|
||||
),
|
||||
),
|
||||
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Option 1: Pass empty function when disabled (doesn't change button appearance)
|
||||
CustomFilledButton(
|
||||
onTap: () => _handleSubmit(context, isSubmitting),
|
||||
label: isSubmitting ? "Submitting..." : "Continue",
|
||||
width: double.infinity,
|
||||
),
|
||||
|
||||
SizedBox(height: 50.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
import 'package:citycards_customer/attraction_details/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_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../core/route_constants.dart';
|
||||
|
||||
class AttractionDetailsView extends StatelessWidget {
|
||||
const AttractionDetailsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Stack(
|
||||
children: [
|
||||
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),
|
||||
Text(
|
||||
"Koh Rong Samloem",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
left: 12.w,
|
||||
child: Text(
|
||||
"Koh Rong\nSamloem",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 44.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// About Section
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 30.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"About",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.32.h),
|
||||
Text(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non...",
|
||||
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: "+1012 3456 789",
|
||||
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: "CityCards24@gmail.com",
|
||||
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),
|
||||
|
||||
Wrap(
|
||||
runSpacing: 16.h,
|
||||
spacing: 16.w,
|
||||
children: [
|
||||
includedBox(
|
||||
"assets/icons/bus.png",
|
||||
"Bus",
|
||||
"Transportation",
|
||||
),
|
||||
includedBox(
|
||||
"assets/icons/clock.png",
|
||||
"2 day 1 night",
|
||||
"Duration",
|
||||
),
|
||||
includedBox(
|
||||
"assets/icons/bx_qr.png",
|
||||
"TAC200812695",
|
||||
"Product code",
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(13.54.r),
|
||||
child: Image.asset(
|
||||
height: 178.7.h,
|
||||
width: double.infinity,
|
||||
"assets/images/attra_detail_map.png",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 17.h),
|
||||
|
||||
CustomText(
|
||||
text:
|
||||
"Angkor Mails Hotel \nNR6, Krong Siem Reap Cambodia",
|
||||
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),
|
||||
|
||||
faqBox(
|
||||
"About this place",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
|
||||
SizedBox(height: 15.h),
|
||||
|
||||
faqBox(
|
||||
"Term and condition",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
|
||||
faqBox(
|
||||
"Cancellation Policy",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(icon, scale: 4),
|
||||
SizedBox(width: 16.w),
|
||||
Column(
|
||||
children: [
|
||||
CustomText(
|
||||
text: title,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFF212121),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
CustomText(
|
||||
text: disc,
|
||||
size: 11.sp,
|
||||
weight: FontWeight.w400,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget faqBox(String title, String desc) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
border: Border.all(color: Color(0xFFFDCDCE)),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CustomText(
|
||||
text: title,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFF212121),
|
||||
),
|
||||
SizedBox(width: 20.w),
|
||||
Icon(Icons.arrow_forward_ios_outlined, size: 18.sp),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 9.h),
|
||||
CustomText(text: desc, size: 11.sp, color: Color(0xFF7D7D7D)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/attraction_details/bloc/attraction_details_bloc.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'attraction_details_event.dart';
|
||||
import 'attraction_details_state.dart';
|
||||
import '../repository/attraction_details_repository.dart';
|
||||
|
||||
class AttractionDetailsBloc
|
||||
extends Bloc<AttractionDetailsEvent, AttractionDetailsState> {
|
||||
final AttractionDetailsRepository repository;
|
||||
|
||||
AttractionDetailsBloc({
|
||||
required this.repository,
|
||||
}) : super(AttractionDetailsInitial()) {
|
||||
on<FetchAttractionDetails>(_onFetchAttractionDetails);
|
||||
}
|
||||
|
||||
Future<void> _onFetchAttractionDetails(
|
||||
FetchAttractionDetails event,
|
||||
Emitter<AttractionDetailsState> emit,
|
||||
) async {
|
||||
emit(AttractionDetailsLoading());
|
||||
|
||||
try {
|
||||
final response = await repository.fetchAttractionDetails(
|
||||
attractionId: event.attractionId,
|
||||
);
|
||||
|
||||
emit(
|
||||
AttractionDetailsLoaded(
|
||||
attractionDetails: response,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AttractionDetailsError(
|
||||
message: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
lib/attraction_details/bloc/attraction_details_event.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class AttractionDetailsEvent extends Equatable {
|
||||
const AttractionDetailsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FetchAttractionDetails extends AttractionDetailsEvent {
|
||||
final int attractionId;
|
||||
|
||||
const FetchAttractionDetails({
|
||||
required this.attractionId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [attractionId];
|
||||
}
|
||||
36
lib/attraction_details/bloc/attraction_details_state.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../models/attraction_details_model.dart';
|
||||
|
||||
abstract class AttractionDetailsState extends Equatable {
|
||||
const AttractionDetailsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AttractionDetailsInitial extends AttractionDetailsState {}
|
||||
|
||||
class AttractionDetailsLoading extends AttractionDetailsState {}
|
||||
|
||||
class AttractionDetailsLoaded extends AttractionDetailsState {
|
||||
final AttractionDetailsModel attractionDetails;
|
||||
|
||||
const AttractionDetailsLoaded({
|
||||
required this.attractionDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [attractionDetails];
|
||||
}
|
||||
|
||||
class AttractionDetailsError extends AttractionDetailsState {
|
||||
final String message;
|
||||
|
||||
const AttractionDetailsError({
|
||||
required this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
246
lib/attraction_details/models/attraction_details_model.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
class AttractionDetailsModel {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final int cityXid;
|
||||
final int? cardTypeXid;
|
||||
final int partnerXid;
|
||||
final String productCode;
|
||||
final String subTitle;
|
||||
final String urlSlug;
|
||||
final bool isBookingRequired;
|
||||
final bool isPartnerAccess;
|
||||
final String bookingEmail;
|
||||
final String bookingPhoneNumber;
|
||||
final String address;
|
||||
final double latitudeCoordinate;
|
||||
final double longitudeCoordinate;
|
||||
final double ticketPriceAdult;
|
||||
final double ticketPriceChild;
|
||||
final int durations;
|
||||
final int groupSize;
|
||||
final String ageRange;
|
||||
final String seoTitle;
|
||||
final String seoDescription;
|
||||
final String attractionStatus;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<AttractionGallery> attractionGalleries;
|
||||
final List<AttractionInclusion> attractionInclusions;
|
||||
final List<AttractionFaq> attractionFaqs;
|
||||
|
||||
AttractionDetailsModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.cityXid,
|
||||
this.cardTypeXid,
|
||||
required this.partnerXid,
|
||||
required this.productCode,
|
||||
required this.subTitle,
|
||||
required this.urlSlug,
|
||||
required this.isBookingRequired,
|
||||
required this.isPartnerAccess,
|
||||
required this.bookingEmail,
|
||||
required this.bookingPhoneNumber,
|
||||
required this.address,
|
||||
required this.latitudeCoordinate,
|
||||
required this.longitudeCoordinate,
|
||||
required this.ticketPriceAdult,
|
||||
required this.ticketPriceChild,
|
||||
required this.durations,
|
||||
required this.groupSize,
|
||||
required this.ageRange,
|
||||
required this.seoTitle,
|
||||
required this.seoDescription,
|
||||
required this.attractionStatus,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.attractionGalleries,
|
||||
required this.attractionInclusions,
|
||||
required this.attractionFaqs,
|
||||
});
|
||||
|
||||
factory AttractionDetailsModel.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionDetailsModel(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
cityXid: json['cityXid'] ?? 0,
|
||||
cardTypeXid: json['cardTypeXid'],
|
||||
partnerXid: json['partnerXid'] ?? 0,
|
||||
productCode: json['productCode'] ?? 'N/A',
|
||||
subTitle: json['subTitle'] ?? 'N/A',
|
||||
urlSlug: json['urlSlug'] ?? 'N/A',
|
||||
isBookingRequired: json['isBookingRequired'] ?? false,
|
||||
isPartnerAccess: json['isPartnerAccess'] ?? false,
|
||||
bookingEmail: json['bookingEmail'] ?? 'N/A',
|
||||
bookingPhoneNumber: json['bookingPhoneNumber'] ?? 'N/A',
|
||||
address: json['address'] ?? 'N/A',
|
||||
latitudeCoordinate: json['latitudeCoordinate'] != null
|
||||
? (json['latitudeCoordinate'] as num).toDouble()
|
||||
: 0.0,
|
||||
longitudeCoordinate: json['longitudeCoordinate'] != null
|
||||
? (json['longitudeCoordinate'] as num).toDouble()
|
||||
: 0.0,
|
||||
ticketPriceAdult: json['ticketPriceAdult'] != null
|
||||
? (json['ticketPriceAdult'] as num).toDouble()
|
||||
: 0.0,
|
||||
ticketPriceChild: json['ticketPriceChild'] != null
|
||||
? (json['ticketPriceChild'] as num).toDouble()
|
||||
: 0.0,
|
||||
durations: json['durations'] ?? 0,
|
||||
groupSize: json['groupSize'] ?? 0,
|
||||
ageRange: json['ageRange'] ?? 'N/A',
|
||||
seoTitle: json['seoTitle'] ?? 'N/A',
|
||||
seoDescription: json['seoDescription'] ?? 'N/A',
|
||||
attractionStatus: json['attractionStatus'] ?? 'N/A',
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: DateTime.now(),
|
||||
attractionGalleries: json['attractionGalleries'] != null
|
||||
? (json['attractionGalleries'] as List)
|
||||
.map((e) => AttractionGallery.fromJson(e))
|
||||
.toList()
|
||||
: [],
|
||||
attractionInclusions: json['attractionInclusions'] != null
|
||||
? (json['attractionInclusions'] as List)
|
||||
.map((e) => AttractionInclusion.fromJson(e))
|
||||
.toList()
|
||||
: [],
|
||||
attractionFaqs: json['attractionFaqs'] != null
|
||||
? (json['attractionFaqs'] as List)
|
||||
.map((e) => AttractionFaq.fromJson(e))
|
||||
.toList()
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// =======================
|
||||
/// Attraction Gallery
|
||||
/// =======================
|
||||
class AttractionGallery {
|
||||
final int id;
|
||||
final int attractionXid;
|
||||
final String fileType;
|
||||
final String filePathUrl;
|
||||
final String altText;
|
||||
final bool isCoverImage;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
AttractionGallery({
|
||||
required this.id,
|
||||
required this.attractionXid,
|
||||
required this.fileType,
|
||||
required this.filePathUrl,
|
||||
required this.altText,
|
||||
required this.isCoverImage,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory AttractionGallery.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionGallery(
|
||||
id: json['id'] ?? 0,
|
||||
attractionXid: json['attractionXid'] ?? 0,
|
||||
fileType: json['fileType'] ?? 'N/A',
|
||||
filePathUrl: json['filePathUrl'] ?? 'N/A',
|
||||
altText: json['altText'] ?? 'N/A',
|
||||
isCoverImage: json['isCoverImage'] ?? false,
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// =======================
|
||||
/// Attraction Inclusion
|
||||
/// =======================
|
||||
class AttractionInclusion {
|
||||
final int id;
|
||||
final int attractionXid;
|
||||
final String title;
|
||||
final String description;
|
||||
final int? iconXid;
|
||||
final bool isInclusion;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
AttractionInclusion({
|
||||
required this.id,
|
||||
required this.attractionXid,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.iconXid,
|
||||
required this.isInclusion,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory AttractionInclusion.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionInclusion(
|
||||
id: json['id'] ?? 0,
|
||||
attractionXid: json['attractionXid'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
iconXid: json['iconXid'],
|
||||
isInclusion: json['isInclusion'] ?? false,
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// =======================
|
||||
/// Attraction FAQ
|
||||
/// =======================
|
||||
class AttractionFaq {
|
||||
final int id;
|
||||
final int attractionXid;
|
||||
final String faqQuestion;
|
||||
final String faqAnswer;
|
||||
final int displayOrder;
|
||||
final bool isActive;
|
||||
|
||||
AttractionFaq({
|
||||
required this.id,
|
||||
required this.attractionXid,
|
||||
required this.faqQuestion,
|
||||
required this.faqAnswer,
|
||||
required this.displayOrder,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory AttractionFaq.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionFaq(
|
||||
id: json['id'] ?? 0,
|
||||
attractionXid: json['attractionXid'] ?? 0,
|
||||
faqQuestion: json['faqQuestion'] ?? 'N/A',
|
||||
faqAnswer: json['faqAnswer'] ?? 'N/A',
|
||||
displayOrder: json['displayOrder'] ?? 0,
|
||||
isActive: json['isActive'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../models/attraction_details_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
class AttractionDetailsRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Fetch attraction details by attractionId
|
||||
Future<AttractionDetailsModel> fetchAttractionDetails({
|
||||
required int attractionId,
|
||||
}) async {
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.attractionDetails}/$attractionId',
|
||||
);
|
||||
|
||||
return AttractionDetailsModel.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
595
lib/attraction_details/views/attraction_details_view.dart
Normal file
@@ -0,0 +1,595 @@
|
||||
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_map/flutter_map.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../core/route_constants.dart';
|
||||
import '../bloc/attraction_details_bloc.dart';
|
||||
import '../bloc/attraction_details_event.dart';
|
||||
import '../bloc/attraction_details_state.dart';
|
||||
import '../repository/attraction_details_repository.dart';
|
||||
|
||||
class AttractionDetailsView extends StatelessWidget {
|
||||
final int? attractionId;
|
||||
|
||||
const AttractionDetailsView({
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// About Section
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.only(left: 16.w, right: 16.w, top: 20.h),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,18 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// drag handle
|
||||
Container(
|
||||
height: 4.h,
|
||||
width: 47.w,
|
||||
margin: EdgeInsets.only(bottom: 16),
|
||||
margin: EdgeInsets.only(bottom: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF222222),
|
||||
color: const Color(0xFF222222),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
|
||||
// link field
|
||||
TextField(
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
@@ -51,7 +54,10 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// grid
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -67,7 +73,16 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(item['icon']!, width: 55.w),
|
||||
// FIXED SIZE ICON CONTAINER
|
||||
Container(
|
||||
width: 55.w,
|
||||
height: 55.w,
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
item['icon']!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
item['title']!,
|
||||
@@ -78,26 +93,32 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// page indicator
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
4,
|
||||
(index) => Container(
|
||||
(index) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: 8.w,
|
||||
height: 8.h,
|
||||
decoration: BoxDecoration(
|
||||
color: index == 0 ? Color(0xFF676363) : Colors.white,
|
||||
border: Border.all(color: Color(0xFF676363)),
|
||||
color: index == 0
|
||||
? const Color(0xFF676363)
|
||||
: Colors.white,
|
||||
border: Border.all(color: const Color(0xFF676363)),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,42 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../models/attraction_model.dart';
|
||||
import '../repository/attractions_repository.dart';
|
||||
|
||||
part 'attractions_event.dart';
|
||||
part 'attractions_state.dart';
|
||||
import 'attractions_event.dart';
|
||||
import 'attractions_state.dart';
|
||||
|
||||
class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
|
||||
final AttractionsRepository repository;
|
||||
|
||||
AttractionsBloc(this.repository) : super(AttractionsInitial()) {
|
||||
on<LoadAttractions>((event, emit) {
|
||||
final attractions = repository.fetchAttractions();
|
||||
emit(AttractionsLoaded(attractions));
|
||||
});
|
||||
|
||||
on<LoadMyPassAttraction>((event, emit) {
|
||||
final attractions = repository.fetchMyPassAttraction();
|
||||
emit(AttractionsLoaded(attractions));
|
||||
});
|
||||
|
||||
on<SearchAttractions>((event, emit) {
|
||||
if (state is AttractionsLoaded) {
|
||||
final currentState = state as AttractionsLoaded;
|
||||
final filtered = currentState.attractions
|
||||
.where((a) =>
|
||||
a.title.toLowerCase().contains(event.query.toLowerCase()) ||
|
||||
a.location.toLowerCase().contains(event.query.toLowerCase()))
|
||||
.toList();
|
||||
emit(AttractionsLoaded(filtered));
|
||||
}
|
||||
});
|
||||
AttractionsBloc({required this.repository})
|
||||
: super(AttractionsInitial()) {
|
||||
on<FetchAttractionsByCategory>(_onFetchAttractionsByCategory);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFetchAttractionsByCategory(
|
||||
FetchAttractionsByCategory event,
|
||||
Emitter<AttractionsState> emit,
|
||||
) async {
|
||||
emit(AttractionsLoading());
|
||||
|
||||
try {
|
||||
final AttractionsResponse response =
|
||||
await repository.fetchAttractionsByCategory(
|
||||
categoryXid: event.categoryXid, // Can be null now
|
||||
);
|
||||
|
||||
emit(
|
||||
AttractionsLoaded(
|
||||
attractions: response.attractions ?? [],
|
||||
categories: response.categories ?? [],
|
||||
selectedCategoryId: event.categoryXid, // Can be null
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AttractionsError(
|
||||
e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
part of 'attractions_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class AttractionsEvent {}
|
||||
abstract class AttractionsEvent extends Equatable {
|
||||
const AttractionsEvent();
|
||||
|
||||
class LoadAttractions extends AttractionsEvent {}
|
||||
|
||||
class LoadMyPassAttraction extends AttractionsEvent {}
|
||||
|
||||
class SearchAttractions extends AttractionsEvent {
|
||||
final String query;
|
||||
SearchAttractions(this.query);
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FetchAttractionsByCategory extends AttractionsEvent {
|
||||
final int? categoryXid; // Make it nullable
|
||||
|
||||
const FetchAttractionsByCategory({this.categoryXid}); // Remove required
|
||||
|
||||
@override
|
||||
List<Object?> get props => [categoryXid];
|
||||
}
|
||||
@@ -1,10 +1,37 @@
|
||||
part of 'attractions_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/attraction_model.dart';
|
||||
|
||||
abstract class AttractionsState {}
|
||||
abstract class AttractionsState extends Equatable {
|
||||
const AttractionsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AttractionsInitial extends AttractionsState {}
|
||||
|
||||
class AttractionsLoading extends AttractionsState {}
|
||||
|
||||
class AttractionsLoaded extends AttractionsState {
|
||||
final List<Attraction> attractions;
|
||||
AttractionsLoaded(this.attractions);
|
||||
final List<Category> categories;
|
||||
final int? selectedCategoryId; // Make it nullable
|
||||
|
||||
const AttractionsLoaded({
|
||||
required this.attractions,
|
||||
required this.categories,
|
||||
this.selectedCategoryId, // Remove required
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [attractions, categories, selectedCategoryId];
|
||||
}
|
||||
|
||||
class AttractionsError extends AttractionsState {
|
||||
final String message;
|
||||
|
||||
const AttractionsError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -1,19 +1,299 @@
|
||||
/* -------------------- RESPONSE -------------------- */
|
||||
|
||||
class AttractionsResponse {
|
||||
final List<Attraction> attractions;
|
||||
final List<Category> categories;
|
||||
|
||||
AttractionsResponse({
|
||||
required this.attractions,
|
||||
required this.categories,
|
||||
});
|
||||
|
||||
factory AttractionsResponse.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionsResponse(
|
||||
attractions: (json['attractions'] as List<dynamic>?)
|
||||
?.map((e) => Attraction.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
categories: (json['categories'] as List<dynamic>?)
|
||||
?.map((e) => Category.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'attractions': attractions.map((e) => e.toJson()).toList(),
|
||||
'categories': categories.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- ATTRACTION -------------------- */
|
||||
|
||||
class Attraction {
|
||||
final int id;
|
||||
final String title;
|
||||
final String location;
|
||||
final String price;
|
||||
final String image;
|
||||
final List<String> tags;
|
||||
final bool isBookingRequired;
|
||||
final String description;
|
||||
final String urlSlug;
|
||||
final num cityXid;
|
||||
final num cardTypeXid;
|
||||
final num partnerXid;
|
||||
final String productCode;
|
||||
|
||||
final bool isBookingRequired;
|
||||
final bool isPartnerAccess;
|
||||
final String bookingEmail;
|
||||
final String bookingPhoneNumber;
|
||||
|
||||
final num latitudeCoordinate;
|
||||
final num longitudeCoordinate;
|
||||
final String address;
|
||||
|
||||
final num? ticketPriceAdult;
|
||||
final num? ticketPriceChild;
|
||||
final num durations;
|
||||
final num groupSize;
|
||||
final String ageRange;
|
||||
|
||||
final String seoTitle;
|
||||
final String seoDescription;
|
||||
final String attractionStatus;
|
||||
final bool isActive;
|
||||
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
final List<CardModel> cards;
|
||||
final List<Category> categories;
|
||||
final List<Gallery> galleries;
|
||||
|
||||
Attraction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.price,
|
||||
required this.image,
|
||||
required this.tags,
|
||||
required this.description,
|
||||
required this.urlSlug,
|
||||
required this.cityXid,
|
||||
required this.cardTypeXid,
|
||||
required this.partnerXid,
|
||||
required this.productCode,
|
||||
required this.isBookingRequired,
|
||||
required this.description
|
||||
required this.isPartnerAccess,
|
||||
required this.bookingEmail,
|
||||
required this.bookingPhoneNumber,
|
||||
required this.latitudeCoordinate,
|
||||
required this.longitudeCoordinate,
|
||||
required this.address,
|
||||
this.ticketPriceAdult,
|
||||
this.ticketPriceChild,
|
||||
required this.durations,
|
||||
required this.groupSize,
|
||||
required this.ageRange,
|
||||
required this.seoTitle,
|
||||
required this.seoDescription,
|
||||
required this.attractionStatus,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.cards,
|
||||
required this.categories,
|
||||
required this.galleries,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic> json) {
|
||||
return Attraction(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
urlSlug: json['urlSlug'] ?? '',
|
||||
cityXid: json['cityXid'] ?? 0,
|
||||
cardTypeXid: json['cardTypeXid'] ?? 0,
|
||||
partnerXid: json['partnerXid'] ?? 0,
|
||||
productCode: json['productCode'] ?? '',
|
||||
isBookingRequired: json['isBookingRequired'] ?? false,
|
||||
isPartnerAccess: json['isPartnerAccess'] ?? false,
|
||||
bookingEmail: json['bookingEmail'] ?? '',
|
||||
bookingPhoneNumber: json['bookingPhonenumber'] ?? '',
|
||||
latitudeCoordinate: (json['latitudeCoordinate'] as num?) ?? 0,
|
||||
longitudeCoordinate: (json['longitudeCoordinate'] as num?) ?? 0,
|
||||
address: json['address'] ?? '',
|
||||
ticketPriceAdult: json['ticketPriceAdult'] as num?,
|
||||
ticketPriceChild: json['ticketPriceChild'] as num?,
|
||||
durations: json['durations'] ?? 0,
|
||||
groupSize: json['groupSize'] ?? 0,
|
||||
ageRange: json['ageRange'] ?? '',
|
||||
seoTitle: json['seoTitle'] ?? '',
|
||||
seoDescription: json['seoDescription'] ?? '',
|
||||
attractionStatus: json['attractionStatus'] ?? '',
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] ?? '',
|
||||
updatedAt: json['updatedAt'] ?? '',
|
||||
cards: (json['cards'] as List<dynamic>?)
|
||||
?.map((e) => CardModel.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
categories: (json['categories'] as List<dynamic>?)
|
||||
?.map((e) => Category.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
galleries: (json['galleries'] as List<dynamic>?)
|
||||
?.map((e) => Gallery.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'urlSlug': urlSlug,
|
||||
'cityXid': cityXid,
|
||||
'cardTypeXid': cardTypeXid,
|
||||
'partnerXid': partnerXid,
|
||||
'productCode': productCode,
|
||||
'isBookingRequired': isBookingRequired,
|
||||
'isPartnerAccess': isPartnerAccess,
|
||||
'bookingEmail': bookingEmail,
|
||||
'bookingPhonenumber': bookingPhoneNumber,
|
||||
'latitudeCoordinate': latitudeCoordinate,
|
||||
'longitudeCoordinate': longitudeCoordinate,
|
||||
'address': address,
|
||||
'ticketPriceAdult': ticketPriceAdult,
|
||||
'ticketPriceChild': ticketPriceChild,
|
||||
'durations': durations,
|
||||
'groupSize': groupSize,
|
||||
'ageRange': ageRange,
|
||||
'seoTitle': seoTitle,
|
||||
'seoDescription': seoDescription,
|
||||
'attractionStatus': attractionStatus,
|
||||
'isActive': isActive,
|
||||
'createdAt': createdAt,
|
||||
'updatedAt': updatedAt,
|
||||
'cards': cards.map((e) => e.toJson()).toList(),
|
||||
'categories': categories.map((e) => e.toJson()).toList(),
|
||||
'galleries': galleries.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 🟢 Helper: Cover image URL (UI-safe)
|
||||
String get coverImageUrl {
|
||||
if (galleries.isEmpty) return '';
|
||||
return galleries
|
||||
.firstWhere(
|
||||
(g) => g.isCoverImage,
|
||||
orElse: () => galleries.first,
|
||||
)
|
||||
.filePathUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- CARD -------------------- */
|
||||
|
||||
class CardModel {
|
||||
final int id;
|
||||
final String title;
|
||||
final num cardTypeXid;
|
||||
final num adultPrice;
|
||||
final num childPrice;
|
||||
final String cardStatus;
|
||||
|
||||
CardModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.cardTypeXid,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
required this.cardStatus,
|
||||
});
|
||||
|
||||
factory CardModel.fromJson(Map<String, dynamic> json) {
|
||||
return CardModel(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? '',
|
||||
cardTypeXid: json['cardTypeXid'] ?? 0,
|
||||
adultPrice: json['adultPrice'] ?? 0,
|
||||
childPrice: json['childPrice'] ?? 0,
|
||||
cardStatus: json['cardStatus'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'cardTypeXid': cardTypeXid,
|
||||
'adultPrice': adultPrice,
|
||||
'childPrice': childPrice,
|
||||
'cardStatus': cardStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- GALLERY -------------------- */
|
||||
|
||||
class Gallery {
|
||||
final int id;
|
||||
final String fileType;
|
||||
final String filePathUrl;
|
||||
final String altText;
|
||||
final bool isCoverImage;
|
||||
|
||||
Gallery({
|
||||
required this.id,
|
||||
required this.fileType,
|
||||
required this.filePathUrl,
|
||||
required this.altText,
|
||||
required this.isCoverImage,
|
||||
});
|
||||
|
||||
factory Gallery.fromJson(Map<String, dynamic> json) {
|
||||
return Gallery(
|
||||
id: json['id'] ?? 0,
|
||||
fileType: json['fileType'] ?? '',
|
||||
filePathUrl: json['filePathUrl'] ?? '',
|
||||
altText: json['altText'] ?? '',
|
||||
isCoverImage: json['isCoverImage'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'fileType': fileType,
|
||||
'filePathUrl': filePathUrl,
|
||||
'altText': altText,
|
||||
'isCoverImage': isCoverImage,
|
||||
};
|
||||
}
|
||||
|
||||
bool get hasImage => filePathUrl.isNotEmpty;
|
||||
}
|
||||
|
||||
/* -------------------- CATEGORY -------------------- */
|
||||
|
||||
class Category {
|
||||
final int id;
|
||||
final String categoryName;
|
||||
|
||||
Category({
|
||||
required this.id,
|
||||
required this.categoryName,
|
||||
});
|
||||
|
||||
factory Category.fromJson(Map<String, dynamic> json) {
|
||||
return Category(
|
||||
id: json['id'] ?? 0,
|
||||
categoryName: json['categoryName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'categoryName': categoryName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,26 @@
|
||||
import 'package:citycards_customer/common_packages/common_app_texts.dart';
|
||||
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../models/attraction_model.dart';
|
||||
|
||||
class AttractionsRepository {
|
||||
List<Attraction> fetchAttractions() {
|
||||
return [
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_1.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Siem Reap",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_2.jpg",
|
||||
tags: ["Unlimited Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_3.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_4.jpg",
|
||||
tags: ["${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_5.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
];
|
||||
}
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
List<Attraction> fetchMyPassAttraction() {
|
||||
return [
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_1.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Siem Reap",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_2.jpg",
|
||||
tags: ["Unlimited Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_3.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_4.jpg",
|
||||
tags: ["${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_5.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
];
|
||||
/// Fetch attractions by categoryXid (optional)
|
||||
Future<AttractionsResponse> fetchAttractionsByCategory({
|
||||
int? categoryXid, // Make it nullable
|
||||
}) async {
|
||||
try {
|
||||
// Build URL with or without categoryXid
|
||||
String url = ApiUrls.attractionsList;
|
||||
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 attractions: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import 'package:citycards_customer/common_packages/back_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../common_packages/custom_search_field.dart';
|
||||
import '../blocs/attractions_bloc.dart';
|
||||
import '../blocs/attractions_event.dart';
|
||||
import '../blocs/attractions_state.dart';
|
||||
import '../repository/attractions_repository.dart';
|
||||
import '../widget/attraction_card.dart';
|
||||
import '../widget/filter_chip.dart';
|
||||
@@ -17,14 +20,13 @@ class AttractionsPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) {
|
||||
final bloc = AttractionsBloc(AttractionsRepository());
|
||||
final bloc = AttractionsBloc(
|
||||
repository: AttractionsRepository(),
|
||||
);
|
||||
|
||||
// 🔥 Trigger event based on source
|
||||
if (source == "home") {
|
||||
bloc.add(LoadAttractions());
|
||||
} else if (source == "qrPass") {
|
||||
bloc.add(LoadMyPassAttraction());
|
||||
}
|
||||
bloc.add(
|
||||
const FetchAttractionsByCategory(), // No categoryXid parameter
|
||||
);
|
||||
|
||||
return bloc;
|
||||
},
|
||||
@@ -41,42 +43,73 @@ class AttractionsPage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// App bar
|
||||
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true),
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showDivider: true,
|
||||
),
|
||||
backWidget(context, "Your Attraction", Colors.black),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 🔍 Search field
|
||||
// 🔍 Search field (UI kept, logic disabled)
|
||||
CommonSearchField(
|
||||
hint: "Search attractions...",
|
||||
hintColor: Colors.grey.shade500,
|
||||
onChanged: (value) {
|
||||
if (value.isEmpty) {
|
||||
bloc.add(LoadAttractions());
|
||||
} else {
|
||||
bloc.add(SearchAttractions(value));
|
||||
}
|
||||
// ❌ Search logic intentionally disabled
|
||||
// UI only, no API call
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🏝️ Category chips row
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
buildCategoryChip("Beach"),
|
||||
buildCategoryChip("Hike"),
|
||||
buildCategoryChip("Popular"),
|
||||
buildCategoryChip("Best in Summer"),
|
||||
],
|
||||
// 🏖️ Category chips row - DYNAMIC
|
||||
if (state is AttractionsLoaded)
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: state.categories
|
||||
.map(
|
||||
(category) => buildCategoryChip(
|
||||
category.categoryName ?? '',
|
||||
isSelected: state.selectedCategoryId == category.id,
|
||||
onTap: () {
|
||||
bloc.add(
|
||||
FetchAttractionsByCategory(
|
||||
categoryXid: category.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// else
|
||||
// // Show placeholder chips while loading
|
||||
// SingleChildScrollView(
|
||||
// scrollDirection: Axis.horizontal,
|
||||
// child: Row(
|
||||
// children: [
|
||||
// buildCategoryChip("Beach", isSelected: true, onTap: () {}),
|
||||
// buildCategoryChip("Hike", isSelected: false, onTap: () {}),
|
||||
// buildCategoryChip("Adventure", isSelected: false, onTap: () {}),
|
||||
// buildCategoryChip("Best in Summer", isSelected: false, onTap: () {}),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 🏙️ Attraction list
|
||||
if (state is AttractionsLoaded)
|
||||
// 🙏️ Attraction list
|
||||
if (state is AttractionsLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (state is AttractionsLoaded)
|
||||
state.attractions.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
@@ -84,7 +117,7 @@ class AttractionsPage extends StatelessWidget {
|
||||
child: Text(
|
||||
"No attractions found",
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
color: Colors.grey,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
@@ -92,17 +125,28 @@ class AttractionsPage extends StatelessWidget {
|
||||
)
|
||||
: Column(
|
||||
children: state.attractions
|
||||
.map((attraction) => AttractionCard(
|
||||
attraction: attraction))
|
||||
.map(
|
||||
(attraction) => AttractionCard(
|
||||
attraction: attraction,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
else
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
else if (state is AttractionsError)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Text(
|
||||
state.message,
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -112,4 +156,4 @@ class AttractionsPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import '../../core/route_constants.dart';
|
||||
@@ -10,64 +11,94 @@ class AttractionCard extends StatelessWidget {
|
||||
|
||||
@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;
|
||||
|
||||
return InkWell(
|
||||
onTap: (){
|
||||
Navigator.of(context).pushNamed(RouteConstants.attractionDetails);
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionDetails,
|
||||
arguments: attraction.id,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: EdgeInsets.symmetric(vertical: 8.h, horizontal: 8.w),
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xffFDCDCE)),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
color: Color(0xffFFF5F5),
|
||||
borderRadius: BorderRadius.circular(15.r),
|
||||
color: const Color(0xffFFF5F5),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// IMAGE (network with fallback)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.asset(
|
||||
attraction.image,
|
||||
height: 94,
|
||||
width: 94,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: imageUrl.isNotEmpty
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
height: 94.h,
|
||||
width: 94.w,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
errorBuilder: (_, __, ___) => _imageFallback(),
|
||||
)
|
||||
: _imageFallback(),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
SizedBox(width: 10.w),
|
||||
|
||||
/// CONTENT
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
attraction.title,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
attraction.location,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xff464646),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
Text(
|
||||
attraction.address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xff464646),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "from ${attraction.price}",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
text: "from \$${attraction.ticketPriceAdult}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
TextSpan(
|
||||
text: "/person",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontSize: 10.sp,
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
@@ -75,63 +106,47 @@ class AttractionCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
attraction.isBookingRequired == false
|
||||
? Wrap(
|
||||
spacing: 6,
|
||||
children: attraction.tags
|
||||
.map(
|
||||
(tag) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
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),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11,
|
||||
color: Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
/// TAGS (CARD TITLES)
|
||||
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),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xffC1D2F8),
|
||||
border: Border.all(
|
||||
color: Color(0xff2563EB),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
"Booking Required",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11,
|
||||
color: Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11.sp,
|
||||
color: const Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -140,4 +155,18 @@ class AttractionCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// SAME PLACEHOLDER AS BEFORE
|
||||
Widget _imageFallback() {
|
||||
return Container(
|
||||
height: 94.h,
|
||||
width: 94.w,
|
||||
color: Colors.grey.shade200,
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
size: 28.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import "package:flutter/material.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Widget buildCategoryChip(String label) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffF95F62),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
Widget buildCategoryChip(
|
||||
String label, {
|
||||
required bool isSelected,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
const Color redColor = Color(0xffF95F62);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? redColor : redColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(
|
||||
color: redColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : redColor,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
100
lib/buy_a_pass/bloc/buy_pass_bloc.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../repository/buy_pass_repository.dart';
|
||||
import 'buy_pass_event.dart';
|
||||
import 'buy_pass_state.dart';
|
||||
|
||||
class BuyPassBloc extends Bloc<BuyPassEvent, BuyPassState> {
|
||||
final BuyPassRepository repository;
|
||||
|
||||
BuyPassBloc({required this.repository}) : super(BuyPassInitial()) {
|
||||
/// Handle fetch buy pass data event
|
||||
on<FetchBuyPassData>(_onFetchBuyPassData);
|
||||
|
||||
/// Handle change selected card event
|
||||
on<ChangeSelectedCard>(_onChangeSelectedCard);
|
||||
|
||||
/// Handle update adult count event
|
||||
on<UpdateAdultCount>(_onUpdateAdultCount);
|
||||
|
||||
/// Handle update child count event
|
||||
on<UpdateChildCount>(_onUpdateChildCount);
|
||||
|
||||
/// Handle update validity duration event
|
||||
on<UpdateValidityDuration>(_onUpdateValidityDuration); // ✅ Added
|
||||
}
|
||||
|
||||
/// Fetch buy pass data from repository
|
||||
Future<void> _onFetchBuyPassData(
|
||||
FetchBuyPassData event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) async {
|
||||
emit(BuyPassLoading());
|
||||
|
||||
try {
|
||||
final data = await repository.fetchBuyPass();
|
||||
emit(BuyPassLoaded(data: data));
|
||||
} catch (e) {
|
||||
emit(BuyPassError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Change selected card
|
||||
void _onChangeSelectedCard(
|
||||
ChangeSelectedCard event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
final newCard = currentState.data.cards[event.cardIndex];
|
||||
|
||||
emit(currentState.copyWith(
|
||||
selectedCardIndex: event.cardIndex,
|
||||
adultCount: 1, // Reset counts when changing card
|
||||
childCount: 1,
|
||||
validityDuration: newCard.minNumber, // ✅ Reset to new card's minNumber
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update adult count
|
||||
void _onUpdateAdultCount(
|
||||
UpdateAdultCount event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
if (event.count >= 0) {
|
||||
emit(currentState.copyWith(adultCount: event.count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update child count
|
||||
void _onUpdateChildCount(
|
||||
UpdateChildCount event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
if (event.count >= 0) {
|
||||
emit(currentState.copyWith(childCount: event.count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update validity duration (days/attractions)
|
||||
void _onUpdateValidityDuration(
|
||||
UpdateValidityDuration event,
|
||||
Emitter<BuyPassState> emit,
|
||||
) {
|
||||
if (state is BuyPassLoaded) {
|
||||
final currentState = state as BuyPassLoaded;
|
||||
final card = currentState.selectedCard;
|
||||
|
||||
// Validate that duration is within min and max range
|
||||
if (event.duration >= card.minNumber && event.duration <= card.maxNumber) {
|
||||
emit(currentState.copyWith(validityDuration: event.duration));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lib/buy_a_pass/bloc/buy_pass_event.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
abstract class BuyPassEvent {}
|
||||
|
||||
/// Event to fetch buy pass data from API
|
||||
class FetchBuyPassData extends BuyPassEvent {}
|
||||
|
||||
/// Event to change the selected card pass
|
||||
class ChangeSelectedCard extends BuyPassEvent {
|
||||
final int cardIndex;
|
||||
|
||||
ChangeSelectedCard(this.cardIndex);
|
||||
}
|
||||
|
||||
/// Event to update adult count
|
||||
class UpdateAdultCount extends BuyPassEvent {
|
||||
final int count;
|
||||
|
||||
UpdateAdultCount(this.count);
|
||||
}
|
||||
|
||||
/// Event to update child count
|
||||
class UpdateChildCount extends BuyPassEvent {
|
||||
final int count;
|
||||
|
||||
UpdateChildCount(this.count);
|
||||
}
|
||||
|
||||
/// Event to update validity duration (days/attractions)
|
||||
class UpdateValidityDuration extends BuyPassEvent {
|
||||
final int duration;
|
||||
|
||||
UpdateValidityDuration(this.duration);
|
||||
}
|
||||
59
lib/buy_a_pass/bloc/buy_pass_state.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import '../models/buy_pass_model.dart';
|
||||
|
||||
abstract class BuyPassState {}
|
||||
|
||||
/// Initial state
|
||||
class BuyPassInitial extends BuyPassState {}
|
||||
|
||||
/// Loading state
|
||||
class BuyPassLoading extends BuyPassState {}
|
||||
|
||||
/// Success state with data
|
||||
class BuyPassLoaded extends BuyPassState {
|
||||
final BuyPassModel data;
|
||||
final int selectedCardIndex;
|
||||
final int adultCount;
|
||||
final int childCount;
|
||||
final int validityDuration; // ✅ Added
|
||||
|
||||
BuyPassLoaded({
|
||||
required this.data,
|
||||
this.selectedCardIndex = 0,
|
||||
this.adultCount = 1,
|
||||
this.childCount = 1,
|
||||
int? validityDuration, // ✅ Added as optional parameter
|
||||
}) : validityDuration = validityDuration ?? data.cards[selectedCardIndex].minNumber; // ✅ Initialize with minNumber
|
||||
|
||||
/// Method to copy state with updated values
|
||||
BuyPassLoaded copyWith({
|
||||
BuyPassModel? data,
|
||||
int? selectedCardIndex,
|
||||
int? adultCount,
|
||||
int? childCount,
|
||||
int? validityDuration, // ✅ Added
|
||||
}) {
|
||||
return BuyPassLoaded(
|
||||
data: data ?? this.data,
|
||||
selectedCardIndex: selectedCardIndex ?? this.selectedCardIndex,
|
||||
adultCount: adultCount ?? this.adultCount,
|
||||
childCount: childCount ?? this.childCount,
|
||||
validityDuration: validityDuration ?? this.validityDuration, // ✅ Added
|
||||
);
|
||||
}
|
||||
|
||||
/// Get currently selected card
|
||||
CardPass get selectedCard => data.cards[selectedCardIndex];
|
||||
|
||||
/// Calculate total price
|
||||
double get totalPrice {
|
||||
final card = selectedCard;
|
||||
return ((card.adultPrice * adultCount) + (card.childPrice * childCount)) * validityDuration.toDouble(); // ✅ Multiply by validityDuration
|
||||
}
|
||||
}
|
||||
|
||||
/// Error state
|
||||
class BuyPassError extends BuyPassState {
|
||||
final String message;
|
||||
|
||||
BuyPassError(this.message);
|
||||
}
|
||||
328
lib/buy_a_pass/models/buy_pass_model.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// ---------- MAIN RESPONSE MODEL ----------
|
||||
BuyPassModel buyPassModelFromJson(String str) =>
|
||||
BuyPassModel.fromJson(json.decode(str));
|
||||
|
||||
String buyPassModelToJson(BuyPassModel data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class BuyPassModel {
|
||||
City city;
|
||||
List<Offer> offers;
|
||||
List<CardPass> cards;
|
||||
List<Attraction> attractions;
|
||||
|
||||
BuyPassModel({
|
||||
required this.city,
|
||||
required this.offers,
|
||||
required this.cards,
|
||||
required this.attractions,
|
||||
});
|
||||
|
||||
factory BuyPassModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return BuyPassModel(
|
||||
city: City.fromJson(json['city']),
|
||||
offers: json['offers'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['offers'])
|
||||
.map((e) => Offer.fromJson(e))
|
||||
.toList(),
|
||||
cards: json['cards'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['cards'])
|
||||
.map((e) => CardPass.fromJson(e))
|
||||
.toList(),
|
||||
attractions: json['attractions'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['attractions'])
|
||||
.map((e) => Attraction.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"city": city.toJson(),
|
||||
"offers": offers.map((e) => e.toJson()).toList(),
|
||||
"cards": cards.map((e) => e.toJson()).toList(),
|
||||
"attractions": attractions.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CITY ----------
|
||||
class City {
|
||||
int id;
|
||||
String name;
|
||||
String slug;
|
||||
String tagLine;
|
||||
String description;
|
||||
String bestTimeToVisit;
|
||||
String priceRange;
|
||||
num individualTicketAmount;
|
||||
num cityCardTicketAmount;
|
||||
HeroBanner heroBanner;
|
||||
|
||||
City({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.slug,
|
||||
required this.tagLine,
|
||||
required this.description,
|
||||
required this.bestTimeToVisit,
|
||||
required this.priceRange,
|
||||
required this.individualTicketAmount,
|
||||
required this.cityCardTicketAmount,
|
||||
required this.heroBanner,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return City(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? "",
|
||||
slug: json['slug']?.toString() ?? "",
|
||||
tagLine: json['tagLine']?.toString() ?? "",
|
||||
description: json['description']?.toString() ?? "",
|
||||
bestTimeToVisit: json['bestTimeToVisit']?.toString() ?? "",
|
||||
priceRange: json['priceRange']?.toString() ?? "",
|
||||
individualTicketAmount: json['individualTicketAmount'] ?? 0,
|
||||
cityCardTicketAmount: json['cityCardTicketAmount'] ?? 0,
|
||||
heroBanner: HeroBanner.fromJson(json['heroBanner']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"tagLine": tagLine,
|
||||
"description": description,
|
||||
"bestTimeToVisit": bestTimeToVisit,
|
||||
"priceRange": priceRange,
|
||||
"individualTicketAmount": individualTicketAmount,
|
||||
"cityCardTicketAmount": cityCardTicketAmount,
|
||||
"heroBanner": heroBanner.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- HERO BANNER ----------
|
||||
class HeroBanner {
|
||||
String title;
|
||||
String image;
|
||||
|
||||
HeroBanner({
|
||||
required this.title,
|
||||
required this.image,
|
||||
});
|
||||
|
||||
factory HeroBanner.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return HeroBanner(
|
||||
title: json['title']?.toString() ?? "",
|
||||
image: json['image']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"title": title,
|
||||
"image": image,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- OFFER ----------
|
||||
class Offer {
|
||||
int id;
|
||||
String title;
|
||||
String offerCode;
|
||||
String description;
|
||||
String redemptionLink;
|
||||
String websiteBannerImage;
|
||||
String mobileBannerImage;
|
||||
String passType;
|
||||
DateTime startDateTime;
|
||||
DateTime endDateTime;
|
||||
String offerStatus;
|
||||
bool applyToPasses;
|
||||
|
||||
Offer({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.offerCode,
|
||||
required this.description,
|
||||
required this.redemptionLink,
|
||||
required this.websiteBannerImage,
|
||||
required this.mobileBannerImage,
|
||||
required this.passType,
|
||||
required this.startDateTime,
|
||||
required this.endDateTime,
|
||||
required this.offerStatus,
|
||||
required this.applyToPasses,
|
||||
});
|
||||
|
||||
factory Offer.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return Offer(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title']?.toString() ?? "",
|
||||
offerCode: json['offerCode']?.toString() ?? "",
|
||||
description: json['description']?.toString() ?? "",
|
||||
redemptionLink: json['redemptionLink']?.toString() ?? "",
|
||||
websiteBannerImage: json['websiteBannerImage']?.toString() ?? "",
|
||||
mobileBannerImage: json['mobileBannerImage']?.toString() ?? "",
|
||||
passType: json['passType']?.toString() ?? "",
|
||||
startDateTime: DateTime.tryParse(json['startDateTime'] ?? "") ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
endDateTime: DateTime.tryParse(json['endDateTime'] ?? "") ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
offerStatus: json['offerStatus']?.toString() ?? "",
|
||||
applyToPasses: json['applyToPasses'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"offerCode": offerCode,
|
||||
"description": description,
|
||||
"redemptionLink": redemptionLink,
|
||||
"websiteBannerImage": websiteBannerImage,
|
||||
"mobileBannerImage": mobileBannerImage,
|
||||
"passType": passType,
|
||||
"startDateTime": startDateTime.toIso8601String(),
|
||||
"endDateTime": endDateTime.toIso8601String(),
|
||||
"offerStatus": offerStatus,
|
||||
"applyToPasses": applyToPasses,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CARD PASS ----------
|
||||
class CardPass {
|
||||
int id;
|
||||
String title;
|
||||
String description;
|
||||
int validityDuration;
|
||||
num adultPrice;
|
||||
num childPrice;
|
||||
int minNumber;
|
||||
int maxNumber;
|
||||
CardType cardType;
|
||||
List<Offer> offers;
|
||||
|
||||
CardPass({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.validityDuration,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
required this.minNumber,
|
||||
required this.maxNumber,
|
||||
required this.cardType,
|
||||
required this.offers,
|
||||
});
|
||||
|
||||
factory CardPass.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CardPass(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title']?.toString() ?? "",
|
||||
description: json['description']?.toString() ?? "",
|
||||
validityDuration: (json['validityDuration'] as num?)?.toInt() ?? 0,
|
||||
adultPrice: json['adultPrice'] ?? 0,
|
||||
childPrice: json['childPrice'] ?? 0,
|
||||
minNumber: (json['minNumber'] as num?)?.toInt() ?? 0,
|
||||
maxNumber: (json['maxNumber'] as num?)?.toInt() ?? 0,
|
||||
cardType: CardType.fromJson(json['cardType']),
|
||||
offers: json['offers'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['offers'])
|
||||
.map((e) => Offer.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"validityDuration": validityDuration,
|
||||
"adultPrice": adultPrice,
|
||||
"childPrice": childPrice,
|
||||
"minNumber": minNumber,
|
||||
"maxNumber": maxNumber,
|
||||
"cardType": cardType.toJson(),
|
||||
"offers": offers.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CARD TYPE ----------
|
||||
class CardType {
|
||||
int id;
|
||||
String name;
|
||||
String displayName;
|
||||
|
||||
CardType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CardType(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? "",
|
||||
displayName: json['displayName']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"displayName": displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- ATTRACTION ----------
|
||||
class Attraction {
|
||||
int id;
|
||||
String title;
|
||||
String slug;
|
||||
String thumbnail;
|
||||
num startingFrom;
|
||||
|
||||
Attraction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.slug,
|
||||
required this.thumbnail,
|
||||
required this.startingFrom,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return Attraction(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title']?.toString() ?? "",
|
||||
slug: json['slug']?.toString() ?? "",
|
||||
thumbnail: json['thumbnail']?.toString() ?? "",
|
||||
startingFrom: json['startingFrom'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"slug": slug,
|
||||
"thumbnail": thumbnail,
|
||||
"startingFrom": startingFrom,
|
||||
};
|
||||
}
|
||||
43
lib/buy_a_pass/models/checkout_model.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
/// Model to pass checkout data from Buy Pass screen to Checkout screen
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CheckoutData {
|
||||
final String cityName;
|
||||
final String heroImage;
|
||||
final String cardTypeName; // "unlimited_card" or "selective_pass"
|
||||
final String cardDisplayName; // "Unlimited" or "Selective"
|
||||
final Color themeColor;
|
||||
final int adultCount;
|
||||
final int childCount;
|
||||
final num adultPrice; // Changed from double to num
|
||||
final num childPrice; // Changed from double to num
|
||||
final int validityDuration; // Days or attractions count
|
||||
final num totalPrice; // Changed from double to num
|
||||
final String? description;
|
||||
|
||||
CheckoutData({
|
||||
required this.cityName,
|
||||
required this.heroImage,
|
||||
required this.cardTypeName,
|
||||
required this.cardDisplayName,
|
||||
required this.themeColor,
|
||||
required this.adultCount,
|
||||
required this.childCount,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
required this.validityDuration,
|
||||
required this.totalPrice,
|
||||
this.description,
|
||||
});
|
||||
|
||||
// Calculate quantity (total adults + children)
|
||||
int get totalQuantity => adultCount + childCount;
|
||||
|
||||
// Check if it's unlimited card
|
||||
bool get isUnlimitedCard => cardTypeName == "unlimited_card";
|
||||
|
||||
// Get validity label
|
||||
String get validityLabel => isUnlimitedCard
|
||||
? "$validityDuration Days"
|
||||
: "$validityDuration Attractions";
|
||||
}
|
||||
54
lib/buy_a_pass/repository/buy_pass_repository.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:citycards_customer/localPreference/local_preference.dart';
|
||||
import '../models/buy_pass_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
|
||||
class BuyPassRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Fetch Buy A Pass data using selected cityId
|
||||
Future<BuyPassModel> fetchBuyPass() async {
|
||||
final int cityId = await LocalPreference.getSelectedCityId();
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.buyAPass}/$cityId',
|
||||
);
|
||||
|
||||
return BuyPassModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
/// Add Passes to Cart
|
||||
Future<Map<String, dynamic>> addToCartPasses({
|
||||
required int cityXid,
|
||||
required int cardTypeXid,
|
||||
required int cardXid,
|
||||
required String cardMode, // flexi / fixed
|
||||
required int totalAdult,
|
||||
required int totalChild,
|
||||
required int noOfAttractions,
|
||||
required int noOfDays,
|
||||
required double baseAmount,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiService.postApi(
|
||||
url: ApiUrls.addToCartPasses,
|
||||
data: {
|
||||
"cityXid": cityXid,
|
||||
"cardTypeXid": cardTypeXid,
|
||||
"cardXid": cardXid,
|
||||
"cardMode": cardMode,
|
||||
"totalAdult": totalAdult,
|
||||
"totalChild": totalChild,
|
||||
"baseAmount": baseAmount,
|
||||
"taxAmount": 2, // Fixed tax amount
|
||||
"noOfAttractions": noOfAttractions,
|
||||
"noOfDays": noOfDays,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to add passes to cart: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,229 +6,494 @@ 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_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../bloc/buy_pass_bloc.dart';
|
||||
import '../bloc/buy_pass_event.dart';
|
||||
import '../bloc/buy_pass_state.dart';
|
||||
import '../repository/buy_pass_repository.dart';
|
||||
|
||||
class BuyPassView extends StatelessWidget {
|
||||
BuyPassView({super.key});
|
||||
const BuyPassView({super.key});
|
||||
|
||||
final availableAttraction = [
|
||||
{"image": "assets/images/aa1.png", "name": "Mystic Falls"},
|
||||
{"image": "assets/images/aa2.png", "name": "Whispering Pines"},
|
||||
{"image": "assets/images/aa3.png", "name": "Enchanted Oasis"},
|
||||
{"image": "assets/images/aa4.png", "name": "Serenity Cove"},
|
||||
];
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => BuyPassBloc(repository: BuyPassRepository())
|
||||
..add(FetchBuyPassData()),
|
||||
child: const BuyPassContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final offers = [
|
||||
{
|
||||
"image": "assets/images/aa1.png",
|
||||
"title": "Astor Hotels Ultra Deluxe",
|
||||
"description": "15% Discount on all treatments for first-time clients",
|
||||
},
|
||||
{
|
||||
"image": "assets/images/aa2.png",
|
||||
"title": "Green Valley Spa Lux",
|
||||
"description": "20% off on spa memberships and treatments",
|
||||
},
|
||||
];
|
||||
class BuyPassContent extends StatelessWidget {
|
||||
const BuyPassContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CommonAppBar(isWhiteLogo: false, isProfilePage: true,showDivider: true,),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Buy a Pass", size: 12.sp),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 20.0.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
PassCardView(themeColor: Color(0xFFF97316)),
|
||||
SizedBox(width: 12.w),
|
||||
PassCardView(themeColor: Color(0xFF1E8AF6),),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 40.h),
|
||||
FeatureTable(),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CustomText(text: "Available Attractions", size: 18.sp),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
...availableAttraction.map((item) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 104.h,
|
||||
width: 104.w,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: Image.asset(
|
||||
item["image"]!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
CustomText(text: item["name"]!, size: 12.sp),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 12.sp,
|
||||
child: BlocBuilder<BuyPassBloc, BuyPassState>(
|
||||
builder: (context, state) {
|
||||
if (state is BuyPassLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
);
|
||||
}
|
||||
|
||||
if (state is BuyPassError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(text: "Card Offers", size: 18.sp),
|
||||
GestureDetector(
|
||||
onTap: (){
|
||||
Navigator.pushNamed(context,RouteConstants.searchOffer);
|
||||
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
|
||||
SizedBox(height: 16.h),
|
||||
CustomText(
|
||||
text: "Error loading data",
|
||||
size: 16.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: state.message,
|
||||
size: 12.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<BuyPassBloc>().add(FetchBuyPassData());
|
||||
},
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 14.sp,
|
||||
color: Color(0xFFFF5757),
|
||||
),
|
||||
child: const Text("Retry"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
height: 262.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: GridView.builder(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.w,
|
||||
childAspectRatio: 0.66,
|
||||
),
|
||||
itemCount: 2,
|
||||
itemBuilder: (context, index) {
|
||||
final offer = offers[index];
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 6.w,
|
||||
vertical: 6.h,
|
||||
);
|
||||
}
|
||||
|
||||
if (state is BuyPassLoaded) {
|
||||
final data = state.data;
|
||||
final selectedCard = state.selectedCard;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: true,
|
||||
showDivider: true,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Color(0xFFF95F62).withOpacity(.24),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.sp),
|
||||
),
|
||||
child: Column(
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.sp),
|
||||
child: Image.asset(
|
||||
offer["image"] ?? "",
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(text: offer["title"] ?? "", size: 18.sp),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: offer["description"] ?? "",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(text: "Buy a Pass", size: 12.sp),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
// Pass Cards Horizontal List
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 20.0.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
data.cards.length,
|
||||
(index) {
|
||||
final card = data.cards[index];
|
||||
final isSelected = index == state.selectedCardIndex;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<BuyPassBloc>().add(
|
||||
ChangeSelectedCard(index),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 12.w),
|
||||
child: PassCardView(
|
||||
themeColor: isSelected
|
||||
? Color(0xFFF97316)
|
||||
: Color(0xFF1E8AF6),
|
||||
city: data.city.name,
|
||||
heroImage: data.city.heroBanner.image,
|
||||
adultPrice: card.adultPrice,
|
||||
childPrice: card.childPrice,
|
||||
cardType: card.cardType.displayName,
|
||||
description: card.description,
|
||||
isSelected: isSelected,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
// Payment Card
|
||||
// ✅ UPDATED PAYMENT CARD SECTION IN buy_pass_view.dart
|
||||
// Replace the existing PaymentCard widget (around line 154) with this:
|
||||
|
||||
Center(
|
||||
child: PaymentCard(
|
||||
city: data.city.name,
|
||||
heroImage: data.city.heroBanner.image,
|
||||
cardType: selectedCard.cardType.name,
|
||||
cardDisplayName: selectedCard.cardType.displayName,
|
||||
themeColor: state.selectedCardIndex == 0
|
||||
? Color(0xFFF97316)
|
||||
: Color(0xFF1E8AF6),
|
||||
adultPrice: selectedCard.adultPrice.toDouble(),
|
||||
childPrice: selectedCard.childPrice.toDouble(),
|
||||
adults: state.adultCount,
|
||||
children: state.childCount,
|
||||
totalPrice: state.totalPrice,
|
||||
minNumber: selectedCard.minNumber,
|
||||
maxNumber: selectedCard.maxNumber,
|
||||
selectedValue: state.validityDuration,
|
||||
description: selectedCard.description,
|
||||
// ✅ NEW: Add these 3 required parameters
|
||||
cityXid: data.city.id,
|
||||
cardTypeXid: selectedCard.cardType.id,
|
||||
cardXid: selectedCard.id,
|
||||
// ✅ END NEW PARAMETERS
|
||||
onAdultChanged: (count) {
|
||||
context.read<BuyPassBloc>().add(
|
||||
UpdateAdultCount(count),
|
||||
);
|
||||
},
|
||||
onChildChanged: (count) {
|
||||
context.read<BuyPassBloc>().add(
|
||||
UpdateChildCount(count),
|
||||
);
|
||||
},
|
||||
onValidityChanged: (duration) {
|
||||
context.read<BuyPassBloc>().add(
|
||||
UpdateValidityDuration(duration),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
FeatureTable(),
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
|
||||
// Card Offers Section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Card Offers", size: 18.sp),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pushNamed(
|
||||
context, RouteConstants.searchOffer);
|
||||
},
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 14.sp,
|
||||
color: Color(0xFFFF5757),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
SizedBox(height: 41.h),
|
||||
Center(
|
||||
child: PaymentCard(
|
||||
city: 'Melbourne',
|
||||
tag: '${CommonAppText.selectiveCard} Card',
|
||||
oldPrice: 120,
|
||||
newPrice: 90,
|
||||
// Offers Grid (from selected card's offers)
|
||||
if (selectedCard.offers.isNotEmpty)
|
||||
Container(
|
||||
height: 262.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16.w,
|
||||
mainAxisSpacing: 22.h,
|
||||
childAspectRatio: 0.65,
|
||||
),
|
||||
itemCount: selectedCard.offers.length > 2
|
||||
? 2
|
||||
: selectedCard.offers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final offer = selectedCard.offers[index];
|
||||
|
||||
return GestureDetector(
|
||||
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: const Color(0xFFF95F62).withOpacity(.24),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.sp),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// Image
|
||||
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: const Color(0xFFFEE7E7),
|
||||
child: Icon(
|
||||
Icons.local_offer,
|
||||
size: 40.sp,
|
||||
color:
|
||||
const Color(0xFFF95F62).withOpacity(.6),
|
||||
),
|
||||
);
|
||||
},
|
||||
loadingBuilder:
|
||||
(context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: const Color(0xFFFEE7E7),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: const Color(0xFFF95F62),
|
||||
value: loadingProgress
|
||||
.expectedTotalBytes !=
|
||||
null
|
||||
? loadingProgress
|
||||
.cumulativeBytesLoaded /
|
||||
loadingProgress
|
||||
.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: double.infinity,
|
||||
height: 120.5.h,
|
||||
color: const Color(0xFFFEE7E7),
|
||||
child: Icon(
|
||||
Icons.local_offer,
|
||||
size: 40.sp,
|
||||
color:
|
||||
const Color(0xFFF95F62).withOpacity(.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
/// Title
|
||||
CustomText(
|
||||
text: offer.title,
|
||||
size: 18.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
/// Offer Code
|
||||
CustomText(
|
||||
text: offer.description??"N/A",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100.h,
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "No offers available",
|
||||
size: 14.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 30.h),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Divider(color: Colors.black.withOpacity(0.1)),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
// Available Attractions
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.0.w),
|
||||
child: CustomText(
|
||||
text: "Available Attractions", size: 18.sp),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
if (data.attractions.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: data.attractions.map((attraction) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 104.h,
|
||||
width: 104.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionDetails,
|
||||
arguments: attraction.id,
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: attraction.thumbnail != null &&
|
||||
attraction.thumbnail!.isNotEmpty
|
||||
? Image.network(
|
||||
attraction.thumbnail!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(
|
||||
Icons.location_on,
|
||||
size: 40.sp,
|
||||
color: Colors.grey[400],
|
||||
);
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20.w,
|
||||
height: 20.w,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
Icons.location_on,
|
||||
size: 40.sp,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
SizedBox(
|
||||
width: 104.w,
|
||||
child: CustomText(
|
||||
text: attraction.title,
|
||||
size: 12.sp,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 100.h,
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "No attractions available",
|
||||
size: 14.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionsPage,
|
||||
arguments: "home",
|
||||
);
|
||||
},
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomText(
|
||||
text: "View All",
|
||||
size: 12.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 41.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ class FeatureTable extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Static data using a simple model
|
||||
final features = [
|
||||
FeatureModel('Access to attractions', true, true),
|
||||
FeatureModel('Entry to attractions', true, true),
|
||||
@@ -16,109 +15,147 @@ class FeatureTable extends StatelessWidget {
|
||||
FeatureModel('Entry to sites', false, true),
|
||||
FeatureModel('Access to venues', true, true),
|
||||
FeatureModel('Entry to events', true, true),
|
||||
FeatureModel('Access to experiences', true, true),
|
||||
FeatureModel('Access to experiences', false, true),
|
||||
FeatureModel('Access to Itinerary creation', false, true),
|
||||
FeatureModel('Access to postcard creation', false, true),
|
||||
];
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF3F3F3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Table(
|
||||
columnWidths: const {
|
||||
0: FlexColumnWidth(2.5),
|
||||
1: FlexColumnWidth(1.2),
|
||||
2: FlexColumnWidth(1.2),
|
||||
},
|
||||
|
||||
children: [
|
||||
_buildHeaderRow(),
|
||||
...features.map((f) => _buildFeatureRow(f)).toList(),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F3F3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Table(
|
||||
columnWidths: const {
|
||||
0: FlexColumnWidth(2.7),
|
||||
1: FlexColumnWidth(1.15),
|
||||
2: FlexColumnWidth(1.15),
|
||||
},
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
children: [
|
||||
_buildHeaderRow(),
|
||||
...features.map(_buildFeatureRow).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Header Row
|
||||
// HEADER ROW
|
||||
TableRow _buildHeaderRow() {
|
||||
return TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
child: Text(
|
||||
'Features',
|
||||
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16.sp),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${CommonAppText.selectiveCard}',
|
||||
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16.sp),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Unlimited',
|
||||
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16.sp),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildHeaderText(CommonAppText.selectiveCard),
|
||||
_buildHeaderText('Unlimited'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Each Feature Row
|
||||
Widget _buildHeaderText(String text) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.visible,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// FEATURE ROW
|
||||
TableRow _buildFeatureRow(FeatureModel feature) {
|
||||
return TableRow(
|
||||
children: [
|
||||
_buildCell(feature.name),
|
||||
_buildFeatureCell(feature.name),
|
||||
_buildIconCell(feature.flexi),
|
||||
_buildIconCell(feature.unlimited),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Text cell
|
||||
Widget _buildCell(String text) {
|
||||
// FEATURE TEXT WITH BULLET
|
||||
Widget _buildFeatureCell(String text) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
child: Text(text, style: TextStyle(fontSize: 12.sp, color: Colors.black.withOpacity(.8)),),
|
||||
padding: EdgeInsets.symmetric(vertical: 7.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 2.h, right: 6.w),
|
||||
child: Text(
|
||||
'•',
|
||||
style: TextStyle(fontSize: 18.sp, height: 1),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
color: Colors.black.withOpacity(0.85),
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Icon cell
|
||||
// ICON CELL
|
||||
Widget _buildIconCell(bool isAvailable) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
padding: EdgeInsets.symmetric(vertical: 7.h),
|
||||
child: Center(
|
||||
child: isAvailable
|
||||
? Icon(Icons.check_circle, color: Colors.redAccent,size: 16.sp,)
|
||||
: const Text('–', style: TextStyle(color: Colors.black54)),
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.redAccent,
|
||||
size: 16.sp,
|
||||
)
|
||||
: Text(
|
||||
'–',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Model for feature row
|
||||
// MODEL
|
||||
class FeatureModel {
|
||||
final String name;
|
||||
final bool flexi;
|
||||
|
||||
@@ -2,22 +2,26 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
|
||||
class PassCardView extends StatelessWidget {
|
||||
final Color? themeColor;
|
||||
final String? city;
|
||||
final int? adultCount;
|
||||
final int? childCount;
|
||||
final String? heroImage; // ✅ heroBanner.image from API
|
||||
final num? adultPrice;
|
||||
final num? childPrice;
|
||||
final String? cardType;
|
||||
final String? description;
|
||||
final bool isSelected;
|
||||
|
||||
const PassCardView({
|
||||
super.key,
|
||||
this.themeColor,
|
||||
this.city,
|
||||
this.adultCount,
|
||||
this.childCount,
|
||||
this.heroImage,
|
||||
this.adultPrice,
|
||||
this.childPrice,
|
||||
this.cardType,
|
||||
this.description,
|
||||
this.isSelected = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -25,141 +29,177 @@ class PassCardView extends StatelessWidget {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color:( themeColor ?? Color(0xFFF95FAF)).withOpacity(0.24)),
|
||||
border: Border.all(
|
||||
color: (themeColor ?? const Color(0xFFF95FAF)).withOpacity(0.24),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
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: Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 103.w,
|
||||
height:140.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.66.w),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Melbourne",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"From ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$80",
|
||||
style: TextStyle(
|
||||
color:themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /Adult",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"and ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$10",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /child",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: 193.w,
|
||||
child: CustomText(
|
||||
text:
|
||||
"Dive into an extensive selection of thrilling destinations!",
|
||||
color: Color(0xFF000000).withOpacity(0.6),
|
||||
size: 11.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 140.h,
|
||||
decoration: BoxDecoration(
|
||||
color: themeColor,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
/// -------- HERO BANNER IMAGE --------
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(8.r),
|
||||
topRight: Radius.circular(8.r),
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
child: Container(
|
||||
width: 103.w,
|
||||
height: 140.h,
|
||||
color: Colors.grey[200],
|
||||
child: heroImage != null && heroImage!.isNotEmpty
|
||||
? Image.network(
|
||||
heroImage!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _fallbackIcon();
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: _fallbackIcon(),
|
||||
),
|
||||
),
|
||||
child: RotatedBox(
|
||||
quarterTurns: -1,
|
||||
child: Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "${CommonAppText.selectiveCard} ",
|
||||
style: TextStyle(color: Colors.white, fontSize: 16.sp),
|
||||
|
||||
SizedBox(width: 6.66.w),
|
||||
|
||||
/// -------- CARD DETAILS --------
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: city ?? "City",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
|
||||
/// Adult Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"From ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
TextSpan(
|
||||
text: "Card",
|
||||
style: TextStyle(color: Colors.white, fontSize: 12.sp),
|
||||
),
|
||||
Text(
|
||||
"\$${adultPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
" /Adult",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// Child Price
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"and ",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"\$${childPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
color: themeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 24.sp,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" /child",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.8),
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// Description
|
||||
SizedBox(
|
||||
width: 193.w,
|
||||
child: CustomText(
|
||||
text: description ??
|
||||
"Dive into an extensive selection of thrilling destinations!",
|
||||
color: const Color(0xFF000000).withOpacity(0.6),
|
||||
size: 11.sp,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// -------- CARD TYPE LABEL --------
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 140.h,
|
||||
decoration: BoxDecoration(
|
||||
color: themeColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(8.r),
|
||||
topRight: Radius.circular(8.r),
|
||||
),
|
||||
),
|
||||
child: RotatedBox(
|
||||
quarterTurns: -1,
|
||||
child: Center(
|
||||
child: Text(
|
||||
cardType ?? "Pass",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// -------- FALLBACK ICON --------
|
||||
Widget _fallbackIcon() {
|
||||
return Icon(
|
||||
Icons.card_travel,
|
||||
size: 40.sp,
|
||||
color: Colors.grey[400],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +1,326 @@
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class PaymentCard extends StatefulWidget {
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../models/checkout_model.dart';
|
||||
import '../../checkout/view/checkout_view.dart';
|
||||
import '../repository/buy_pass_repository.dart'; // ✅ Import repository
|
||||
|
||||
class PaymentCard extends StatelessWidget {
|
||||
final String city;
|
||||
final String tag;
|
||||
final double oldPrice;
|
||||
final double newPrice;
|
||||
final String heroImage;
|
||||
final String cardType;
|
||||
final String cardDisplayName;
|
||||
final Color themeColor;
|
||||
final double adultPrice;
|
||||
final double childPrice;
|
||||
final int adults;
|
||||
final int children;
|
||||
final double totalPrice;
|
||||
final int minNumber;
|
||||
final int maxNumber;
|
||||
final int selectedValue;
|
||||
final String? description;
|
||||
final Function(int) onAdultChanged;
|
||||
final Function(int) onChildChanged;
|
||||
final Function(int) onValidityChanged;
|
||||
|
||||
// ✅ NEW: Required parameters for API call
|
||||
final int cityXid;
|
||||
final int cardTypeXid;
|
||||
final int cardXid;
|
||||
|
||||
const PaymentCard({
|
||||
super.key,
|
||||
required this.city,
|
||||
required this.tag,
|
||||
required this.oldPrice,
|
||||
required this.newPrice,
|
||||
required this.heroImage,
|
||||
required this.cardType,
|
||||
required this.cardDisplayName,
|
||||
required this.themeColor,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
required this.adults,
|
||||
required this.children,
|
||||
required this.totalPrice,
|
||||
required this.minNumber,
|
||||
required this.maxNumber,
|
||||
required this.selectedValue,
|
||||
this.description,
|
||||
required this.onAdultChanged,
|
||||
required this.onChildChanged,
|
||||
required this.onValidityChanged,
|
||||
required this.cityXid, // ✅ NEW
|
||||
required this.cardTypeXid, // ✅ NEW
|
||||
required this.cardXid, // ✅ NEW
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentCard> createState() => _PaymentCardState();
|
||||
}
|
||||
|
||||
class _PaymentCardState extends State<PaymentCard> {
|
||||
int adults = 1;
|
||||
int children = 1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 320,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.pinkAccent, width: 1.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.pinkAccent.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
widget.city,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
final bool isUnlimitedCard = cardType == "unlimited_card";
|
||||
final bool isSelectivePass = cardType == "selective_pass";
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Tag
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95FAF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(20.sp),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.pinkAccent, width: 1.2),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.pinkAccent.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
child: Text(
|
||||
widget.tag,
|
||||
style: const TextStyle(
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CustomText(
|
||||
text: city,
|
||||
size: 20.sp,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95FAF),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: CustomText(
|
||||
text: cardDisplayName,
|
||||
size: 12.sp,
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Adult Counter
|
||||
_buildCounterRow("No. of Adults", adults, (val) {
|
||||
setState(() => adults = val);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Children Counter
|
||||
_buildCounterRow("No. of Children", children, (val) {
|
||||
setState(() => children = val);
|
||||
}),
|
||||
|
||||
const Divider(height: 30, thickness: 1),
|
||||
|
||||
// Price section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"You Pay",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
SizedBox(height: 16.h),
|
||||
_buildCounterRow("No. of Adults", adults, onAdultChanged),
|
||||
SizedBox(height: 10.h),
|
||||
_buildCounterRow("No. of Children", children, onChildChanged),
|
||||
SizedBox(height: 10.h),
|
||||
if (isUnlimitedCard)
|
||||
_buildDropdownRow(
|
||||
label: "No. of Days",
|
||||
value: selectedValue,
|
||||
onChanged: onValidityChanged,
|
||||
)
|
||||
else if (isSelectivePass)
|
||||
_buildDropdownRow(
|
||||
label: "No. of Attractions",
|
||||
value: selectedValue,
|
||||
onChanged: onValidityChanged,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"\$${widget.oldPrice.toStringAsFixed(0)}",
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"\$${widget.newPrice.toStringAsFixed(0)}",
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFF95F62),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(height: 30.h, thickness: 1),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "You Pay",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
CustomText(
|
||||
text: "\$${totalPrice.toStringAsFixed(0)}",
|
||||
size: 18.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
CustomFilledButton(
|
||||
onTap: () async {
|
||||
try {
|
||||
// ✅ Check login status first
|
||||
final bool isLoggedIn = await LocalPreference.getLogin();
|
||||
|
||||
const SizedBox(height: 20),
|
||||
// ✅ Create checkout data (needed for both cases)
|
||||
final checkoutData = CheckoutData(
|
||||
cityName: city,
|
||||
heroImage: heroImage,
|
||||
cardTypeName: cardType,
|
||||
cardDisplayName: cardDisplayName,
|
||||
themeColor: themeColor,
|
||||
adultCount: adults,
|
||||
childCount: children,
|
||||
adultPrice: adultPrice,
|
||||
childPrice: childPrice,
|
||||
validityDuration: selectedValue,
|
||||
totalPrice: totalPrice,
|
||||
description: description,
|
||||
);
|
||||
|
||||
// Proceed Button
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteConstants.checkout);
|
||||
},
|
||||
label: "Proceed to Pay",
|
||||
),
|
||||
],
|
||||
// ✅ Save to local preference (for both logged in and guest users)
|
||||
await LocalPreference.setPassCart(
|
||||
cityName: city,
|
||||
heroImage: heroImage,
|
||||
cardTypeName: cardType,
|
||||
cardDisplayName: cardDisplayName,
|
||||
themeColor: themeColor.value,
|
||||
adultCount: adults,
|
||||
childCount: children,
|
||||
adultPrice: adultPrice,
|
||||
childPrice: childPrice,
|
||||
validityDuration: selectedValue,
|
||||
totalPrice: totalPrice,
|
||||
description: description,
|
||||
);
|
||||
|
||||
if (isLoggedIn) {
|
||||
// ✅ User is logged in - hit API
|
||||
final repository = BuyPassRepository();
|
||||
final response = await repository.addToCartPasses(
|
||||
cityXid: cityXid,
|
||||
cardTypeXid: cardTypeXid,
|
||||
cardXid: cardXid,
|
||||
cardMode: isSelectivePass ? 'flexi' : 'unlimited',
|
||||
totalAdult: adults,
|
||||
totalChild: children,
|
||||
noOfAttractions: isSelectivePass ? selectedValue : 0,
|
||||
noOfDays: isUnlimitedCard ? selectedValue : 0,
|
||||
baseAmount: totalPrice,
|
||||
);
|
||||
|
||||
// ✅ Extract bookingId from response
|
||||
final int bookingId = response['id'];
|
||||
|
||||
// ✅ Navigate to checkout with bookingId
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CheckoutView(bookingId: bookingId),
|
||||
settings: RouteSettings(
|
||||
arguments: checkoutData,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// ✅ User is NOT logged in - skip API, navigate directly
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CheckoutView(bookingId: 0), // or 0, depending on your CheckoutView implementation
|
||||
settings: RouteSettings(
|
||||
arguments: checkoutData,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ✅ Show error message
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to proceed: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
label: "Proceed to Pay",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCounterRow(String label, int value, Function(int) onChanged) {
|
||||
Widget _buildDropdownRow({
|
||||
required String label,
|
||||
required int value,
|
||||
required Function(int) onChanged,
|
||||
}) {
|
||||
List<int> numbersList = List.generate(
|
||||
maxNumber - minNumber + 1,
|
||||
(index) => minNumber + index,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: 15.sp)),
|
||||
CustomText(
|
||||
text: label,
|
||||
size: 15.sp,
|
||||
),
|
||||
Container(
|
||||
height: 36.h,
|
||||
width: 88.w,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62).withValues(alpha: 0.13),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFF95F62),
|
||||
width: 1.4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
icon: Icon(
|
||||
Icons.keyboard_arrow_down_rounded,
|
||||
color: const Color(0xFFF95F62),
|
||||
size: 22.sp,
|
||||
),
|
||||
items: numbersList.map((int number) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: number,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: "$number",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (int? newValue) {
|
||||
if (newValue != null) {
|
||||
onChanged(newValue);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCounterRow(
|
||||
String label,
|
||||
int value,
|
||||
Function(int) onChanged,
|
||||
) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: label, size: 15.sp),
|
||||
Row(
|
||||
children: [
|
||||
_circleButton(Icons.remove, () {
|
||||
if (value > 0) onChanged(value - 1);
|
||||
}),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
"$value",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||
child: CustomText(
|
||||
text: "$value",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
_circleButton(Icons.add, () {
|
||||
@@ -173,9 +341,9 @@ class _PaymentCardState extends State<PaymentCard> {
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
padding: const EdgeInsets.all(4),
|
||||
padding: EdgeInsets.all(4.sp),
|
||||
child: Icon(icon, color: Colors.white, size: 18.sp),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
177
lib/cart/blocs/myPassCart/my_pass_cart_bloc.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repository/my_pass_cart_repository.dart';
|
||||
import 'my_pass_cart_event.dart';
|
||||
import 'my_pass_cart_state.dart';
|
||||
|
||||
class MyPassCartBloc extends Bloc<MyPassCartEvent, MyPassCartState> {
|
||||
final MyPassCartRepository repository;
|
||||
|
||||
MyPassCartBloc({required this.repository}) : super(const MyPassCartInitial()) {
|
||||
on<CheckLoginAndFetchEvent>(_onCheckLoginAndFetch);
|
||||
on<FetchPassCartEvent>(_onFetchPassCart);
|
||||
on<ClearPassCartEvent>(_onClearPassCart);
|
||||
}
|
||||
|
||||
/// Handle checking login status and fetching cart data accordingly
|
||||
Future<void> _onCheckLoginAndFetch(
|
||||
CheckLoginAndFetchEvent event,
|
||||
Emitter<MyPassCartState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔍 [BLOC] Checking login status and fetching cart...');
|
||||
}
|
||||
|
||||
emit(const MyPassCartLoading());
|
||||
|
||||
// Check if user is logged in
|
||||
final isLoggedIn = await repository.isUserLoggedIn();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('🔐 [BLOC] User logged in: $isLoggedIn');
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
// User is logged in - fetch from API
|
||||
if (kDebugMode) {
|
||||
print('🌐 [BLOC] Fetching cart data from API...');
|
||||
}
|
||||
|
||||
try {
|
||||
final apiCartData = await repository.fetchMyPassesCart();
|
||||
|
||||
// Check if API data is empty
|
||||
if (apiCartData.cartItems.isEmpty) {
|
||||
if (kDebugMode) {
|
||||
print('⚠️ [BLOC] API returned empty cart, checking local data...');
|
||||
}
|
||||
|
||||
// Try to fetch from local if API is empty
|
||||
final localCartData = await repository.fetchPassesCartByLocal();
|
||||
|
||||
if (localCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Using local cart data as fallback');
|
||||
}
|
||||
emit(MyPassCartLoaded(cartData: localCartData));
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('ℹ️ [BLOC] No local data available, cart is empty');
|
||||
}
|
||||
emit(const MyPassCartEmpty());
|
||||
}
|
||||
} else {
|
||||
// API has cart items
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] API cart data loaded successfully with ${apiCartData.cartItems.length} items');
|
||||
}
|
||||
emit(MyPassCartApiLoaded(apiCartData: apiCartData));
|
||||
}
|
||||
} catch (apiError) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] API error: $apiError, trying local data...');
|
||||
}
|
||||
|
||||
// API failed, try local data as fallback
|
||||
final localCartData = await repository.fetchPassesCartByLocal();
|
||||
|
||||
if (localCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Using local cart data after API failure');
|
||||
}
|
||||
emit(MyPassCartLoaded(cartData: localCartData));
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] No local data available after API failure');
|
||||
}
|
||||
emit(MyPassCartError(message: 'Failed to load cart data: ${apiError.toString()}'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is not logged in - fetch from local only
|
||||
if (kDebugMode) {
|
||||
print('📱 [BLOC] User not logged in, fetching from local storage...');
|
||||
}
|
||||
|
||||
final localCartData = await repository.fetchPassesCartByLocal();
|
||||
|
||||
if (localCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Local cart data loaded successfully');
|
||||
}
|
||||
emit(MyPassCartLoaded(cartData: localCartData));
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('ℹ️ [BLOC] No local cart data available');
|
||||
}
|
||||
emit(const MyPassCartEmpty());
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] Error in CheckLoginAndFetch: $e');
|
||||
}
|
||||
emit(MyPassCartError(message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle fetching pass cart data from local storage
|
||||
Future<void> _onFetchPassCart(
|
||||
FetchPassCartEvent event,
|
||||
Emitter<MyPassCartState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('📄 [BLOC] Fetching pass cart from local...');
|
||||
}
|
||||
|
||||
emit(const MyPassCartLoading());
|
||||
|
||||
final cartData = await repository.fetchPassesCartByLocal();
|
||||
|
||||
if (cartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Cart data loaded successfully');
|
||||
}
|
||||
emit(MyPassCartLoaded(cartData: cartData));
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('ℹ️ [BLOC] Cart is empty');
|
||||
}
|
||||
emit(const MyPassCartEmpty());
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] Error fetching cart: $e');
|
||||
}
|
||||
emit(MyPassCartError(message: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle clearing pass cart
|
||||
Future<void> _onClearPassCart(
|
||||
ClearPassCartEvent event,
|
||||
Emitter<MyPassCartState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('📄 [BLOC] Clearing pass cart...');
|
||||
}
|
||||
|
||||
// You can add clearPassCart method to repository if needed
|
||||
// await repository.clearPassCartFromLocal();
|
||||
|
||||
emit(const MyPassCartEmpty());
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ [BLOC] Cart cleared successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [BLOC] Error clearing cart: $e');
|
||||
}
|
||||
emit(MyPassCartError(message: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
26
lib/cart/blocs/myPassCart/my_pass_cart_event.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class MyPassCartEvent extends Equatable {
|
||||
const MyPassCartEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Event to check login status and fetch pass cart data accordingly
|
||||
/// - If logged in: fetch from API
|
||||
/// - If not logged in: fetch from local
|
||||
/// - If API returns empty and local data exists: use local data
|
||||
class CheckLoginAndFetchEvent extends MyPassCartEvent {
|
||||
const CheckLoginAndFetchEvent();
|
||||
}
|
||||
|
||||
/// Event to fetch pass cart data from local database
|
||||
class FetchPassCartEvent extends MyPassCartEvent {
|
||||
const FetchPassCartEvent();
|
||||
}
|
||||
|
||||
/// Event to clear pass cart
|
||||
class ClearPassCartEvent extends MyPassCartEvent {
|
||||
const ClearPassCartEvent();
|
||||
}
|
||||
55
lib/cart/blocs/myPassCart/my_pass_cart_state.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../model/my_passes_cart_mode.dart';
|
||||
|
||||
abstract class MyPassCartState extends Equatable {
|
||||
const MyPassCartState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Initial state
|
||||
class MyPassCartInitial extends MyPassCartState {
|
||||
const MyPassCartInitial();
|
||||
}
|
||||
|
||||
/// Loading state when fetching cart data
|
||||
class MyPassCartLoading extends MyPassCartState {
|
||||
const MyPassCartLoading();
|
||||
}
|
||||
|
||||
/// Loaded state with cart data from local storage
|
||||
class MyPassCartLoaded extends MyPassCartState {
|
||||
final Map<String, dynamic> cartData;
|
||||
|
||||
const MyPassCartLoaded({required this.cartData});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cartData];
|
||||
}
|
||||
|
||||
/// Loaded state with cart data from API
|
||||
class MyPassCartApiLoaded extends MyPassCartState {
|
||||
final MyPassesCartModel apiCartData;
|
||||
|
||||
const MyPassCartApiLoaded({required this.apiCartData});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [apiCartData];
|
||||
}
|
||||
|
||||
/// Empty state when no cart data exists
|
||||
class MyPassCartEmpty extends MyPassCartState {
|
||||
const MyPassCartEmpty();
|
||||
}
|
||||
|
||||
/// Error state
|
||||
class MyPassCartError extends MyPassCartState {
|
||||
final String message;
|
||||
|
||||
const MyPassCartError({required this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -1,40 +1,40 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../model/pass_model.dart';
|
||||
|
||||
abstract class PassEvent {}
|
||||
class LoadPasses extends PassEvent {}
|
||||
|
||||
abstract class PassState {}
|
||||
class PassLoading extends PassState {}
|
||||
class PassLoaded extends PassState {
|
||||
final List<PassModel> passes;
|
||||
final double subtotal;
|
||||
final double discountPercent;
|
||||
final double total;
|
||||
|
||||
PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
|
||||
}
|
||||
|
||||
class PassBloc extends Bloc<PassEvent, PassState> {
|
||||
PassBloc() : super(PassLoading()) {
|
||||
on<LoadPasses>((event, emit) {
|
||||
final passes = [
|
||||
PassModel(
|
||||
title: "Melbourne",
|
||||
imageUrl: "assets/images/city_melbourne.png",
|
||||
duration: "2 days",
|
||||
adults: 3,
|
||||
kids: 3,
|
||||
quantity: 2,
|
||||
price: 49.50,
|
||||
discount: 7.2,
|
||||
),
|
||||
];
|
||||
|
||||
final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
|
||||
final discountPercent = passes.first.discount;
|
||||
final total = subtotal - (subtotal * discountPercent / 100);
|
||||
emit(PassLoaded(passes, subtotal, discountPercent, total));
|
||||
});
|
||||
}
|
||||
}
|
||||
// import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
// import '../model/pass_model.dart';
|
||||
//
|
||||
// abstract class PassEvent {}
|
||||
// class LoadPasses extends PassEvent {}
|
||||
//
|
||||
// abstract class PassState {}
|
||||
// class PassLoading extends PassState {}
|
||||
// class PassLoaded extends PassState {
|
||||
// final List<PassModel> passes;
|
||||
// final double subtotal;
|
||||
// final double discountPercent;
|
||||
// final double total;
|
||||
//
|
||||
// PassLoaded(this.passes, this.subtotal, this.discountPercent, this.total);
|
||||
// }
|
||||
//
|
||||
// class PassBloc extends Bloc<PassEvent, PassState> {
|
||||
// PassBloc() : super(PassLoading()) {
|
||||
// on<LoadPasses>((event, emit) {
|
||||
// final passes = [
|
||||
// PassModel(
|
||||
// title: "Melbourne",
|
||||
// imageUrl: "assets/images/city_melbourne.png",
|
||||
// duration: "2 days",
|
||||
// adults: 3,
|
||||
// kids: 3,
|
||||
// quantity: 2,
|
||||
// price: 49.50,
|
||||
// discount: 7.2,
|
||||
// ),
|
||||
// ];
|
||||
//
|
||||
// final subtotal = passes.fold(0.0, (sum, item) => sum + item.price);
|
||||
// final discountPercent = passes.first.discount;
|
||||
// final total = subtotal - (subtotal * discountPercent / 100);
|
||||
// emit(PassLoaded(passes, subtotal, discountPercent, total));
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
207
lib/cart/model/my_passes_cart_mode.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// ---------- MAIN RESPONSE ----------
|
||||
MyPassesCartModel myPassesCartModelFromJson(String str) =>
|
||||
MyPassesCartModel.fromJson(json.decode(str));
|
||||
|
||||
String myPassesCartModelToJson(MyPassesCartModel data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class MyPassesCartModel {
|
||||
CartCity city;
|
||||
List<CartItem> cartItems;
|
||||
|
||||
MyPassesCartModel({
|
||||
required this.city,
|
||||
required this.cartItems,
|
||||
});
|
||||
|
||||
factory MyPassesCartModel.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return MyPassesCartModel(
|
||||
city: CartCity.fromJson(json['city']),
|
||||
cartItems: json['cartItems'] == null
|
||||
? []
|
||||
: List<Map<String, dynamic>>.from(json['cartItems'])
|
||||
.map((e) => CartItem.fromJson(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"city": city.toJson(),
|
||||
"cartItems": cartItems.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CITY ----------
|
||||
class CartCity {
|
||||
int id;
|
||||
String name;
|
||||
|
||||
CartCity({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory CartCity.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CartCity(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CART ITEM ----------
|
||||
class CartItem {
|
||||
int id;
|
||||
String bookingNumber;
|
||||
String cardMode;
|
||||
int noOfDays;
|
||||
int noOfAttractions;
|
||||
int totalAdult;
|
||||
int totalChild;
|
||||
num baseAmount;
|
||||
num totalTaxAmount;
|
||||
num totalAmount;
|
||||
String bookingStatus;
|
||||
bool isForSelf;
|
||||
String recipientFirstName;
|
||||
String recipientLastName;
|
||||
String recipientEmail;
|
||||
String recipientPhone;
|
||||
String recipientCity;
|
||||
String recipientCountry;
|
||||
String giftMessage;
|
||||
bool isPaymentRequired;
|
||||
int couponXid;
|
||||
num couponDiscountAmount;
|
||||
num couponDiscountPercent;
|
||||
String paymentStatus;
|
||||
String createdAt;
|
||||
ItemCity city;
|
||||
|
||||
CartItem({
|
||||
required this.id,
|
||||
required this.bookingNumber,
|
||||
required this.cardMode,
|
||||
required this.noOfDays,
|
||||
required this.noOfAttractions,
|
||||
required this.totalAdult,
|
||||
required this.totalChild,
|
||||
required this.baseAmount,
|
||||
required this.totalTaxAmount,
|
||||
required this.totalAmount,
|
||||
required this.bookingStatus,
|
||||
required this.isForSelf,
|
||||
required this.recipientFirstName,
|
||||
required this.recipientLastName,
|
||||
required this.recipientEmail,
|
||||
required this.recipientPhone,
|
||||
required this.recipientCity,
|
||||
required this.recipientCountry,
|
||||
required this.giftMessage,
|
||||
required this.isPaymentRequired,
|
||||
required this.couponXid,
|
||||
required this.couponDiscountAmount,
|
||||
required this.couponDiscountPercent,
|
||||
required this.paymentStatus,
|
||||
required this.createdAt,
|
||||
required this.city,
|
||||
});
|
||||
|
||||
factory CartItem.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return CartItem(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
bookingNumber: json['bookingNumber']?.toString() ?? "",
|
||||
cardMode: json['cardMode']?.toString() ?? "",
|
||||
noOfDays: (json['noOfDays'] as num?)?.toInt() ?? 0,
|
||||
noOfAttractions: (json['noOfAttractions'] as num?)?.toInt() ?? 0,
|
||||
totalAdult: (json['totalAdult'] as num?)?.toInt() ?? 0,
|
||||
totalChild: (json['totalChild'] as num?)?.toInt() ?? 0,
|
||||
baseAmount: json['baseAmount'] ?? 0,
|
||||
totalTaxAmount: json['totalTaxAmount'] ?? 0,
|
||||
totalAmount: json['totalAmount'] ?? 0,
|
||||
bookingStatus: json['bookingStatus']?.toString() ?? "",
|
||||
isForSelf: json['isForSelf'] ?? false,
|
||||
recipientFirstName: json['recipientFirstName']?.toString() ?? "",
|
||||
recipientLastName: json['recipientLastName']?.toString() ?? "",
|
||||
recipientEmail: json['recipientEmail']?.toString() ?? "",
|
||||
recipientPhone: json['recipientPhone']?.toString() ?? "",
|
||||
recipientCity: json['recipientCity']?.toString() ?? "",
|
||||
recipientCountry: json['recipientCountry']?.toString() ?? "",
|
||||
giftMessage: json['giftMessage']?.toString() ?? "",
|
||||
isPaymentRequired: json['isPaymentRequired'] ?? false,
|
||||
couponXid: (json['couponXid'] as num?)?.toInt() ?? 0,
|
||||
couponDiscountAmount: json['couponDiscountAmount'] ?? 0,
|
||||
couponDiscountPercent: json['couponDiscountPercent'] ?? 0,
|
||||
paymentStatus: json['paymentStatus']?.toString() ?? "",
|
||||
createdAt: json['createdAt']?.toString() ?? "",
|
||||
city: ItemCity.fromJson(json['city']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"bookingNumber": bookingNumber,
|
||||
"cardMode": cardMode,
|
||||
"noOfDays": noOfDays,
|
||||
"noOfAttractions": noOfAttractions,
|
||||
"totalAdult": totalAdult,
|
||||
"totalChild": totalChild,
|
||||
"baseAmount": baseAmount,
|
||||
"totalTaxAmount": totalTaxAmount,
|
||||
"totalAmount": totalAmount,
|
||||
"bookingStatus": bookingStatus,
|
||||
"isForSelf": isForSelf,
|
||||
"recipientFirstName": recipientFirstName,
|
||||
"recipientLastName": recipientLastName,
|
||||
"recipientEmail": recipientEmail,
|
||||
"recipientPhone": recipientPhone,
|
||||
"recipientCity": recipientCity,
|
||||
"recipientCountry": recipientCountry,
|
||||
"giftMessage": giftMessage,
|
||||
"isPaymentRequired": isPaymentRequired,
|
||||
"couponXid": couponXid,
|
||||
"couponDiscountAmount": couponDiscountAmount,
|
||||
"couponDiscountPercent": couponDiscountPercent,
|
||||
"paymentStatus": paymentStatus,
|
||||
"createdAt": createdAt,
|
||||
"city": city.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- ITEM CITY ----------
|
||||
class ItemCity {
|
||||
int id;
|
||||
String cityName;
|
||||
|
||||
ItemCity({
|
||||
required this.id,
|
||||
required this.cityName,
|
||||
});
|
||||
|
||||
factory ItemCity.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
|
||||
return ItemCity(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
cityName: json['cityName']?.toString() ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"cityName": cityName,
|
||||
};
|
||||
}
|
||||
83
lib/cart/repository/my_pass_cart_repository.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../model/my_passes_cart_mode.dart';
|
||||
|
||||
class MyPassCartRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Check if user is logged in
|
||||
Future<bool> isUserLoggedIn() async {
|
||||
try {
|
||||
final isLogin = await LocalPreference.getLogin();
|
||||
if (kDebugMode) {
|
||||
print('🔐 [REPO] User login status: $isLogin');
|
||||
}
|
||||
return isLogin;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [REPO] Error checking login status: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch pass cart data from local database
|
||||
Future<Map<String, dynamic>?> fetchPassesCartByLocal() async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('📄 [REPO] Fetching pass cart from local database...');
|
||||
}
|
||||
|
||||
final passCartData = await LocalPreference.getPassCart();
|
||||
|
||||
|
||||
if (passCartData != null) {
|
||||
if (kDebugMode) {
|
||||
print('✅ [REPO] Pass cart retrieved successfully');
|
||||
print('📦 [REPO] Cart details: ${passCartData['card_display_name']} - ${passCartData['city_name']}');
|
||||
}
|
||||
return passCartData;
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('ℹ️ [REPO] No pass cart data found in local database');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('❌ [REPO] Error fetching pass cart: $e');
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../../common_packages/back_widget.dart';
|
||||
import '../blocs/pass_bloc.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_bloc.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_event.dart';
|
||||
import '../blocs/postcard_bloc.dart';
|
||||
import 'my_pass_page_view.dart';
|
||||
import '../repository/my_pass_cart_repository.dart';
|
||||
import 'my_pass_cart_page_view.dart';
|
||||
import 'my_postcard_page_view.dart';
|
||||
|
||||
class MyCartPage extends StatefulWidget {
|
||||
@@ -22,8 +24,14 @@ class _MyCartPageState extends State<MyCartPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => PassBloc()..add(LoadPasses())),
|
||||
BlocProvider(create: (_) => PostCardBloc()..add(LoadPostCards())),
|
||||
BlocProvider(
|
||||
create: (_) => PostCardBloc()..add(LoadPostCards()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => MyPassCartBloc(
|
||||
repository: MyPassCartRepository(),
|
||||
)..add(const FetchPassCartEvent()),
|
||||
),
|
||||
],
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
|
||||
892
lib/cart/views/my_pass_cart_page_view.dart
Normal file
@@ -0,0 +1,892 @@
|
||||
import 'package:citycards_customer/cart/views/view_pass_page_view.dart';
|
||||
import 'package:citycards_customer/checkout/widget/all_coupons_bottomsheet.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_dashed_line.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../../add_details/add_details_view.dart';
|
||||
import '../../checkout/widget/pass_purchase_details_bottomsheet.dart';
|
||||
import '../../login/view/login_email_bottomsheet.dart';
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_bloc.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_event.dart';
|
||||
import '../blocs/myPassCart/my_pass_cart_state.dart';
|
||||
|
||||
class MyPassesPage extends StatefulWidget {
|
||||
const MyPassesPage({super.key});
|
||||
|
||||
@override
|
||||
State<MyPassesPage> createState() => _MyPassesPageState();
|
||||
}
|
||||
|
||||
class _MyPassesPageState extends State<MyPassesPage> {
|
||||
// For coupon/discount management
|
||||
String? appliedCouponCode;
|
||||
double discountPercentage = 0.0;
|
||||
bool isPurchaseDetailsConfirmed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Fetch cart data when page loads
|
||||
context.read<MyPassCartBloc>().add(const CheckLoginAndFetchEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MyPassCartBloc, MyPassCartState>(
|
||||
builder: (context, state) {
|
||||
if (state is MyPassCartLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// ========== HANDLE API DATA (LOGGED IN USER) ==========
|
||||
else if (state is MyPassCartApiLoaded) {
|
||||
final apiCartData = state.apiCartData;
|
||||
|
||||
if (apiCartData.cartItems.isEmpty) {
|
||||
return const Center(child: Text('Your cart is empty'));
|
||||
}
|
||||
|
||||
// Get first cart item (you can modify to handle multiple items)
|
||||
final cartItem = apiCartData.cartItems.first;
|
||||
|
||||
// Extract data from API cart item
|
||||
final String cityName = cartItem.city.cityName;
|
||||
final String heroImage = ''; // API doesn't have hero_image
|
||||
final String cardTypeName = cartItem.cardMode;
|
||||
final String cardDisplayName = cartItem.cardMode;
|
||||
final int themeColor = 0xFFF95FAF;
|
||||
final int adultCount = cartItem.totalAdult;
|
||||
final int childCount = cartItem.totalChild;
|
||||
final int validityDuration = cartItem.noOfDays;
|
||||
final double totalPrice = cartItem.totalAmount.toDouble();
|
||||
|
||||
// Calculate pricing
|
||||
final double subtotal = cartItem.baseAmount.toDouble();
|
||||
final double discountAmount = cartItem.couponDiscountAmount.toDouble();
|
||||
final double totalBeforeTax = subtotal - discountAmount;
|
||||
final double taxAmount = cartItem.totalTaxAmount.toDouble();
|
||||
final double finalTotal = totalPrice;
|
||||
|
||||
// Determine if unlimited card
|
||||
final bool isUnlimitedCard = cardTypeName.toLowerCase().contains("unlimited");
|
||||
final String validityLabel = isUnlimitedCard
|
||||
? "$validityDuration Days"
|
||||
: "${cartItem.noOfAttractions} 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: Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.66.w),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: cityName,
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
CustomText(
|
||||
text: validityLabel,
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * .5,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/adult.png',
|
||||
scale: 4,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text: "$adultCount ${adultCount == 1 ? 'adult' : 'adults'}",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/qty.png',
|
||||
scale: 4,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "Qty:",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF8E8E8E),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${adultCount + childCount}",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF000000),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
"assets/icons/kid.png",
|
||||
scale: 4,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text: "$childCount ${childCount == 1 ? 'Kid' : 'Kids'}",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
SizedBox(width: 53.w),
|
||||
CustomText(
|
||||
text: "\$${totalPrice.toStringAsFixed(2)}",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 123.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(themeColor),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(8.r),
|
||||
topRight: Radius.circular(8.r),
|
||||
),
|
||||
),
|
||||
child: RotatedBox(
|
||||
quarterTurns: -1,
|
||||
child: Center(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "$cardDisplayName ",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: Color(0xFFBB474A).withOpacity(0.4),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: (cartItem.couponDiscountAmount > 0 || appliedCouponCode != null)
|
||||
? "Coupon Applied (${(cartItem.couponDiscountAmount > 0 ? cartItem.couponDiscountPercent : discountPercentage).toStringAsFixed(0)}% off)"
|
||||
: "Get 10% off on your first trip",
|
||||
color: Color(0xFF262626),
|
||||
size: 14.sp,
|
||||
),
|
||||
SizedBox(height: 7.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => AllCouponsBottomsheet(),
|
||||
);
|
||||
},
|
||||
child: CustomText(
|
||||
text: "View all coupons",
|
||||
color: Color(0xFFF95F62),
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 3.w),
|
||||
Icon(Icons.arrow_right, color: Color(0xFFF95F62)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
// Only show Apply/Remove button if no API coupon is applied
|
||||
if (cartItem.couponDiscountAmount == 0)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (appliedCouponCode == null) {
|
||||
appliedCouponCode = "FIRST10";
|
||||
discountPercentage = 10.0;
|
||||
} else {
|
||||
appliedCouponCode = null;
|
||||
discountPercentage = 0.0;
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 10.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: CustomText(
|
||||
text: appliedCouponCode != null ? "Remove" : "Apply",
|
||||
color: Color(0xFFF95F62),
|
||||
size: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
DashedDivider(
|
||||
color: Color(0xFFACACAC),
|
||||
thickness: 1.h,
|
||||
dashLength: 4,
|
||||
dashSpace: 4,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
// Calculate final discount and totals
|
||||
Builder(
|
||||
builder: (context) {
|
||||
// Use API discount if available, otherwise use local discount
|
||||
final effectiveDiscountAmount = cartItem.couponDiscountAmount > 0
|
||||
? cartItem.couponDiscountAmount
|
||||
: (subtotal * (discountPercentage / 100));
|
||||
|
||||
final effectiveDiscountPercent = cartItem.couponDiscountAmount > 0
|
||||
? cartItem.couponDiscountPercent
|
||||
: discountPercentage;
|
||||
|
||||
// Calculate tax on subtotal after discount
|
||||
final subtotalAfterDiscount = subtotal - effectiveDiscountAmount;
|
||||
final calculatedTax = subtotalAfterDiscount * 0.01; // 1% tax
|
||||
final calculatedTotal = subtotalAfterDiscount + calculatedTax;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Subtotal", size: 14.sp),
|
||||
CustomText(
|
||||
text: "\$${subtotal.toStringAsFixed(2)}",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
if (effectiveDiscountAmount > 0) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Discount", size: 14.sp),
|
||||
CustomText(
|
||||
text: "-\$${effectiveDiscountAmount.toStringAsFixed(2)} (${effectiveDiscountPercent.toStringAsFixed(0)}%)",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
],
|
||||
DashedDivider(
|
||||
color: Color(0xFFACACAC),
|
||||
thickness: 1.h,
|
||||
dashLength: 4,
|
||||
dashSpace: 4,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: 'Total', size: 14.sp),
|
||||
SizedBox(height: 4.h),
|
||||
CustomText(
|
||||
text: "Including \$${calculatedTax.toStringAsFixed(2)} in taxes",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CustomText(
|
||||
text: "\$${calculatedTotal.toStringAsFixed(2)}",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 150.h),
|
||||
FutureBuilder<bool>(
|
||||
future: LocalPreference.getLogin(),
|
||||
builder: (context, snapshot) {
|
||||
final isLoggedIn = snapshot.data ?? false;
|
||||
|
||||
return CustomFilledButton(
|
||||
onTap: () async {
|
||||
if (isLoggedIn) {
|
||||
if (isPurchaseDetailsConfirmed) {
|
||||
print("✅ Ready to pay: \$${calculatedTotal.toStringAsFixed(2)}");
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Payment integration pending'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final result = await PassPurchaseBottomSheet.show(
|
||||
context,
|
||||
bookingId: cartItem.id,
|
||||
);
|
||||
|
||||
if (result == 'success') {
|
||||
setState(() {
|
||||
isPurchaseDetailsConfirmed = true;
|
||||
});
|
||||
} else if (result == 'gift') {
|
||||
final giftResult = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => AddDetailsView(bookingId: cartItem.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (giftResult == 'success') {
|
||||
setState(() {
|
||||
isPurchaseDetailsConfirmed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.white,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => const LoginEmailBottomsheet(),
|
||||
);
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
label: isLoggedIn
|
||||
? (isPurchaseDetailsConfirmed
|
||||
? "Pay \$${calculatedTotal.toStringAsFixed(2)}"
|
||||
: "Checkout")
|
||||
: "Login to Checkout",
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 25.h),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ========== HANDLE LOCAL DATA (NOT LOGGED IN) ==========
|
||||
else if (state is MyPassCartLoaded) {
|
||||
final cartData = state.cartData;
|
||||
|
||||
// Extract data from cart
|
||||
final String cityName = cartData['city_name'] as String? ?? '';
|
||||
final String heroImage = cartData['hero_image'] as String? ?? '';
|
||||
final String cardTypeName = cartData['card_type_name'] as String? ?? '';
|
||||
final String cardDisplayName = cartData['card_display_name'] as String? ?? '';
|
||||
final int themeColor = cartData['theme_color'] as int? ?? 0xFFF95FAF;
|
||||
final int adultCount = cartData['adult_count'] as int? ?? 0;
|
||||
final int childCount = cartData['child_count'] as int? ?? 0;
|
||||
final double adultPrice = (cartData['adult_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final double childPrice = (cartData['child_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final int validityDuration = cartData['validity_duration'] as int? ?? 0;
|
||||
final double totalPrice = (cartData['total_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final String? description = cartData['description'] as String?;
|
||||
|
||||
// Calculate pricing
|
||||
final double subtotal = totalPrice;
|
||||
final double discountAmount = subtotal * (discountPercentage / 100);
|
||||
final double totalBeforeTax = subtotal - discountAmount;
|
||||
final double taxAmount = 2;
|
||||
final double finalTotal = totalBeforeTax + taxAmount;
|
||||
|
||||
// Determine if unlimited card
|
||||
final bool isUnlimitedCard = cardTypeName == "unlimited_card";
|
||||
final String validityLabel = isUnlimitedCard
|
||||
? "$validityDuration Days"
|
||||
: "$validityDuration Attractions";
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 22.h),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: Color(themeColor).withOpacity(0.2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(8.r),
|
||||
bottomLeft: Radius.circular(8.r),
|
||||
),
|
||||
child: heroImage.isNotEmpty
|
||||
? Image.network(
|
||||
heroImage,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.asset(
|
||||
"assets/images/card_banner.png",
|
||||
scale: 4,
|
||||
width: 105.w,
|
||||
height: 123.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 6.66.w),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: cityName,
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
CustomText(
|
||||
text: validityLabel,
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * .5,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
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: "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(),
|
||||
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),
|
||||
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 (discountPercentage > 0) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Discount", size: 14.sp),
|
||||
CustomText(
|
||||
text: "-\$${discountAmount.toStringAsFixed(2)} (${discountPercentage.toStringAsFixed(0)}%)",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
],
|
||||
DashedDivider(
|
||||
color: 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 \$${taxAmount.toStringAsFixed(2)} in taxes",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CustomText(
|
||||
text: "\$${finalTotal.toStringAsFixed(2)}",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 150.h),
|
||||
],
|
||||
);
|
||||
}
|
||||
else if (state is MyPassCartEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset("assets/gif/empty_cart.gif", width: 250.w),
|
||||
CustomText(
|
||||
text: "You do not have any passes",
|
||||
size: 24.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
"Get a pass and get offers and discounts and more on your trip to your favourite city",
|
||||
style: TextStyle(color: Color(0xFF656565), fontSize: 14.sp),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (state is MyPassCartError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 60.sp, color: Colors.red),
|
||||
SizedBox(height: 16.h),
|
||||
CustomText(
|
||||
text: "Error loading cart",
|
||||
size: 16.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: state.message,
|
||||
size: 12.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
import 'package:citycards_customer/cart/views/view_pass_page_view.dart';
|
||||
import 'package:citycards_customer/checkout/widget/all_coupons_bottomsheet.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_dashed_line.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../../checkout/widget/login_email_bottomsheet.dart';
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import '../blocs/pass_bloc.dart';
|
||||
|
||||
class MyPassesPage extends StatelessWidget {
|
||||
const MyPassesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PassBloc, PassState>(
|
||||
builder: (context, state) {
|
||||
if (state is PassLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is PassLoaded) {
|
||||
return
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 22.h),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: Color(0xFFF95FAF).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: 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: "Melbourne",
|
||||
weight: FontWeight.w500,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
CustomText(
|
||||
text: "2 Days",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * .5,
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/adult.png',
|
||||
scale: 4,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
CustomText(
|
||||
text: "3 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: " 2",
|
||||
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: "3 Kids",
|
||||
color: Color(0xFF8E8E8E),
|
||||
size: 12.sp,
|
||||
),
|
||||
|
||||
SizedBox(width: 53.w),
|
||||
|
||||
CustomText(
|
||||
text: "\$49.50",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Container(
|
||||
width: 35.w,
|
||||
height: 123.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF97316),
|
||||
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: "${CommonAppText.selectiveCard} ",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: "Card",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12.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: "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(),
|
||||
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: "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),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Subtotal", size: 14.sp),
|
||||
CustomText(
|
||||
text: "\$49.50",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CustomText(text: "Discount", size: 14.sp),
|
||||
CustomText(
|
||||
text: "-7.20%",
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 10.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 \$2.24 in taxes",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CustomText(
|
||||
text: "\$42.60",
|
||||
size: 24.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 150.h,),
|
||||
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
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: "Proceed to Checkout",
|
||||
),
|
||||
SizedBox(height: 25.h),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset("assets/gif/empty_cart.gif", width: 250.w),
|
||||
CustomText(
|
||||
text: "You do not have any passes",
|
||||
size: 24.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
"Get a pass and get offers and discounts and more on your trip to your favourite city",
|
||||
style: TextStyle(color: Color(0xFF656565), fontSize: 14.sp),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import '../../checkout/widget/login_email_bottomsheet.dart';
|
||||
import '../../login/view/login_email_bottomsheet.dart';
|
||||
import '../blocs/postcard_bloc.dart';
|
||||
|
||||
class MyPostCardsPage extends StatelessWidget {
|
||||
|
||||
25
lib/checkout/bloc/allCoupons/all_coupons_bloc.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repository/all_coupons_repository.dart';
|
||||
import 'all_coupons_event.dart';
|
||||
import 'all_coupons_state.dart';
|
||||
|
||||
class AllCouponsBloc extends Bloc<AllCouponsEvent, AllCouponsState> {
|
||||
final AllCouponsRepository repository;
|
||||
|
||||
AllCouponsBloc({required this.repository}) : super(AllCouponsInitialState()) {
|
||||
on<FetchAllCouponsEvent>(_onFetchAllCoupons);
|
||||
}
|
||||
|
||||
Future<void> _onFetchAllCoupons(
|
||||
FetchAllCouponsEvent event,
|
||||
Emitter<AllCouponsState> emit,
|
||||
) async {
|
||||
emit(CouponsLoadingState());
|
||||
try {
|
||||
final coupons = await repository.fetchAllCoupons();
|
||||
emit(CouponsLoadedState(coupons: coupons));
|
||||
} catch (e) {
|
||||
emit(CouponsErrorState(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
3
lib/checkout/bloc/allCoupons/all_coupons_event.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
abstract class AllCouponsEvent {}
|
||||
|
||||
class FetchAllCouponsEvent extends AllCouponsEvent {}
|
||||
19
lib/checkout/bloc/allCoupons/all_coupons_state.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import '../../models/all_coupons_model.dart';
|
||||
|
||||
abstract class AllCouponsState {}
|
||||
|
||||
class AllCouponsInitialState extends AllCouponsState {}
|
||||
|
||||
class CouponsLoadingState extends AllCouponsState {}
|
||||
|
||||
class CouponsLoadedState extends AllCouponsState {
|
||||
final List<AllCouponsModel> coupons;
|
||||
|
||||
CouponsLoadedState({required this.coupons});
|
||||
}
|
||||
|
||||
class CouponsErrorState extends AllCouponsState {
|
||||
final String error;
|
||||
|
||||
CouponsErrorState({required this.error});
|
||||
}
|
||||
261
lib/checkout/bloc/checkOut/checkout_bloc.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repository/all_coupons_repository.dart';
|
||||
import '../../repository/checkout_repository.dart';
|
||||
import 'checkout_event.dart';
|
||||
import 'checkout_state.dart';
|
||||
|
||||
class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
|
||||
final AllCouponsRepository couponsRepository;
|
||||
final CheckoutRepository checkoutRepository;
|
||||
|
||||
CheckoutBloc({
|
||||
required this.couponsRepository,
|
||||
required this.checkoutRepository,
|
||||
}) : super(CheckoutInitialState()) {
|
||||
on<FetchCheckoutCouponsEvent>(_onFetchCheckoutCoupons);
|
||||
on<ApplyCouponEvent>(_onApplyCoupon);
|
||||
on<RemoveCouponEvent>(_onRemoveCoupon);
|
||||
on<ApplyCouponToBackendEvent>(_onApplyCouponToBackend); // 🆕 NEW
|
||||
on<InitiatePaymentEvent>(_onInitiatePayment); // 🆕 NEW
|
||||
on<ConfirmPaymentEvent>(_onConfirmPayment); // 🆕 NEW
|
||||
}
|
||||
|
||||
Future<void> _onFetchCheckoutCoupons(
|
||||
FetchCheckoutCouponsEvent event,
|
||||
Emitter<CheckoutState> emit,
|
||||
) async {
|
||||
emit(CheckoutCouponsLoadingState());
|
||||
try {
|
||||
final coupons = await couponsRepository.fetchAllCoupons();
|
||||
emit(CheckoutCouponsLoadedState(coupons: coupons));
|
||||
} catch (e) {
|
||||
emit(CheckoutCouponsErrorState(error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onApplyCoupon(
|
||||
ApplyCouponEvent event,
|
||||
Emitter<CheckoutState> emit,
|
||||
) {
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(appliedCoupon: event.coupon));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRemoveCoupon(
|
||||
RemoveCouponEvent 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 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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 Initiate Payment
|
||||
/// Calls the /pay API to get clientSecret for Stripe
|
||||
Future<void> _onInitiatePayment(
|
||||
InitiatePaymentEvent event,
|
||||
Emitter<CheckoutState> emit,
|
||||
) async {
|
||||
// Show loading state
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(
|
||||
isInitiatingPayment: true,
|
||||
paymentError: null,
|
||||
clientSecret: null,
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentInitiatingState());
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the /pay API
|
||||
final response = await checkoutRepository.initiatePayment(
|
||||
bookingId: event.bookingId,
|
||||
);
|
||||
|
||||
// Extract clientSecret and bookingId from response
|
||||
final clientSecret = response['clientSecret'] as String?;
|
||||
final bookingId = response['bookingId'] as int?;
|
||||
|
||||
// Validate response
|
||||
if (clientSecret == null || clientSecret.isEmpty) {
|
||||
emit(CheckoutPaymentInitiationErrorState(
|
||||
error: 'Payment initialization failed - no client secret received from server',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookingId == null) {
|
||||
emit(CheckoutPaymentInitiationErrorState(
|
||||
error: 'Payment initialization failed - no booking ID received from server',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit success state with clientSecret
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(
|
||||
isInitiatingPayment: false,
|
||||
clientSecret: clientSecret,
|
||||
bookingId: bookingId,
|
||||
paymentError: null,
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentInitiatedState(
|
||||
clientSecret: clientSecret,
|
||||
bookingId: bookingId,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(
|
||||
isInitiatingPayment: false,
|
||||
paymentError: e.toString(),
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentInitiationErrorState(
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 Confirm Payment
|
||||
/// Called after Stripe payment succeeds or fails
|
||||
/// Sends stripeStatus and paymentStatus to backend
|
||||
Future<void> _onConfirmPayment(
|
||||
ConfirmPaymentEvent event,
|
||||
Emitter<CheckoutState> emit,
|
||||
) async {
|
||||
// 🔒 GUARD: Prevent duplicate confirmation calls
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
if (currentState.hasConfirmationBeenSent) {
|
||||
print('⚠️ [CHECKOUT BLOC] Payment confirmation already sent. Ignoring duplicate call.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(
|
||||
isConfirmingPayment: true,
|
||||
confirmationError: null,
|
||||
isPaymentConfirmed: false,
|
||||
hasConfirmationBeenSent: true, // 🔒 Mark as sent
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentConfirmingState());
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the confirm-payment API
|
||||
final response = await checkoutRepository.confirmPayment(
|
||||
bookingId: event.bookingId,
|
||||
stripeStatus: event.stripeStatus,
|
||||
paymentStatus: event.paymentStatus,
|
||||
);
|
||||
|
||||
// Emit success state with booking details
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(
|
||||
isConfirmingPayment: false,
|
||||
isPaymentConfirmed: true,
|
||||
confirmationError: null,
|
||||
bookingDetails: response,
|
||||
clearClientSecret: true,
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentConfirmedState(
|
||||
bookingDetails: response,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(
|
||||
isConfirmingPayment: false,
|
||||
isPaymentConfirmed: false,
|
||||
confirmationError: e.toString(),
|
||||
hasConfirmationBeenSent: false, // 🔓 Reset on error to allow retry
|
||||
));
|
||||
} else {
|
||||
emit(CheckoutPaymentConfirmationErrorState(
|
||||
error: e.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
lib/checkout/bloc/checkOut/checkout_event.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import '../../models/all_coupons_model.dart';
|
||||
|
||||
abstract class CheckoutEvent {}
|
||||
|
||||
class FetchCheckoutCouponsEvent extends CheckoutEvent {}
|
||||
|
||||
class ApplyCouponEvent extends CheckoutEvent {
|
||||
final AllCouponsModel coupon;
|
||||
ApplyCouponEvent({required this.coupon});
|
||||
}
|
||||
/// 🆕 Apply Coupon to Backend Event
|
||||
class ApplyCouponToBackendEvent extends CheckoutEvent {
|
||||
final int bookingId;
|
||||
final String couponCode;
|
||||
|
||||
ApplyCouponToBackendEvent({
|
||||
required this.bookingId,
|
||||
required this.couponCode,
|
||||
});
|
||||
}
|
||||
|
||||
class RemoveCouponEvent extends CheckoutEvent {
|
||||
final int bookingId;
|
||||
|
||||
RemoveCouponEvent({required this.bookingId});
|
||||
}
|
||||
|
||||
/// 🆕 Initiate Payment Event
|
||||
/// Triggered when user clicks "Pay" button
|
||||
class InitiatePaymentEvent extends CheckoutEvent {
|
||||
final int bookingId;
|
||||
|
||||
InitiatePaymentEvent({required this.bookingId});
|
||||
}
|
||||
|
||||
/// 🆕 Confirm Payment Event
|
||||
/// Triggered after Stripe payment completes (success or failure)
|
||||
class ConfirmPaymentEvent extends CheckoutEvent {
|
||||
final int bookingId;
|
||||
final String stripeStatus; // e.g., "succeeded", "requires_payment_method"
|
||||
final String paymentStatus; // e.g., "success", "failed"
|
||||
|
||||
ConfirmPaymentEvent({
|
||||
required this.bookingId,
|
||||
required this.stripeStatus,
|
||||
required this.paymentStatus,
|
||||
});
|
||||
}
|
||||
123
lib/checkout/bloc/checkOut/checkout_state.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import '../../models/all_coupons_model.dart';
|
||||
|
||||
abstract class CheckoutState {}
|
||||
|
||||
class CheckoutInitialState extends CheckoutState {}
|
||||
|
||||
class CheckoutCouponsLoadingState extends CheckoutState {}
|
||||
|
||||
class CheckoutCouponsLoadedState extends CheckoutState {
|
||||
final List<AllCouponsModel> coupons;
|
||||
final AllCouponsModel? appliedCoupon;
|
||||
|
||||
// 🆕 Coupon application tracking
|
||||
final bool isApplyingCoupon;
|
||||
final String? couponError;
|
||||
|
||||
// 🆕 Payment-related fields
|
||||
final bool isInitiatingPayment;
|
||||
final String? clientSecret; // Stripe client secret
|
||||
final int? bookingId; // Booking ID from payment initiation
|
||||
final String? paymentError;
|
||||
|
||||
// 🆕 Payment confirmation tracking
|
||||
final bool isConfirmingPayment;
|
||||
final bool isPaymentConfirmed;
|
||||
final String? confirmationError;
|
||||
final Map<String, dynamic>? bookingDetails; // Full booking response after confirmation
|
||||
final bool hasConfirmationBeenSent; // 🔒 Prevent duplicate confirmation calls
|
||||
|
||||
CheckoutCouponsLoadedState({
|
||||
required this.coupons,
|
||||
this.appliedCoupon,
|
||||
this.isApplyingCoupon = false,
|
||||
this.couponError,
|
||||
this.isInitiatingPayment = false,
|
||||
this.clientSecret,
|
||||
this.bookingId,
|
||||
this.paymentError,
|
||||
this.isConfirmingPayment = false,
|
||||
this.isPaymentConfirmed = false,
|
||||
this.confirmationError,
|
||||
this.bookingDetails,
|
||||
this.hasConfirmationBeenSent = false,
|
||||
});
|
||||
|
||||
CheckoutCouponsLoadedState copyWith({
|
||||
List<AllCouponsModel>? coupons,
|
||||
AllCouponsModel? appliedCoupon,
|
||||
bool clearAppliedCoupon = false,
|
||||
bool? isApplyingCoupon,
|
||||
String? couponError,
|
||||
bool? isInitiatingPayment,
|
||||
String? clientSecret,
|
||||
int? bookingId,
|
||||
String? paymentError,
|
||||
bool? isConfirmingPayment,
|
||||
bool? isPaymentConfirmed,
|
||||
String? confirmationError,
|
||||
bool clearClientSecret = false,
|
||||
Map<String, dynamic>? bookingDetails,
|
||||
bool? hasConfirmationBeenSent,
|
||||
}) {
|
||||
return CheckoutCouponsLoadedState(
|
||||
coupons: coupons ?? this.coupons,
|
||||
appliedCoupon: clearAppliedCoupon ? null : (appliedCoupon ?? this.appliedCoupon),
|
||||
isApplyingCoupon: isApplyingCoupon ?? this.isApplyingCoupon,
|
||||
couponError: couponError,
|
||||
isInitiatingPayment: isInitiatingPayment ?? this.isInitiatingPayment,
|
||||
bookingId: bookingId ?? this.bookingId,
|
||||
paymentError: paymentError,
|
||||
isConfirmingPayment: isConfirmingPayment ?? this.isConfirmingPayment,
|
||||
isPaymentConfirmed: isPaymentConfirmed ?? this.isPaymentConfirmed,
|
||||
confirmationError: confirmationError,
|
||||
clientSecret: clearClientSecret ? null : (clientSecret ?? this.clientSecret),
|
||||
bookingDetails: bookingDetails ?? this.bookingDetails,
|
||||
hasConfirmationBeenSent: hasConfirmationBeenSent ?? this.hasConfirmationBeenSent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CheckoutCouponsErrorState extends CheckoutState {
|
||||
final String error;
|
||||
CheckoutCouponsErrorState({required this.error});
|
||||
}
|
||||
|
||||
/// 🆕 Payment Initiation Loading State
|
||||
class CheckoutPaymentInitiatingState extends CheckoutState {}
|
||||
|
||||
/// 🆕 Payment Initiation Success State
|
||||
/// This state contains the clientSecret for Stripe payment
|
||||
class CheckoutPaymentInitiatedState extends CheckoutState {
|
||||
final String clientSecret;
|
||||
final int bookingId;
|
||||
|
||||
CheckoutPaymentInitiatedState({
|
||||
required this.clientSecret,
|
||||
required this.bookingId,
|
||||
});
|
||||
}
|
||||
|
||||
/// 🆕 Payment Initiation Error State
|
||||
class CheckoutPaymentInitiationErrorState extends CheckoutState {
|
||||
final String error;
|
||||
|
||||
CheckoutPaymentInitiationErrorState({required this.error});
|
||||
}
|
||||
|
||||
/// 🆕 Payment Confirmation Loading State
|
||||
class CheckoutPaymentConfirmingState extends CheckoutState {}
|
||||
|
||||
/// 🆕 Payment Confirmation Success State
|
||||
class CheckoutPaymentConfirmedState extends CheckoutState {
|
||||
final Map<String, dynamic> bookingDetails;
|
||||
|
||||
CheckoutPaymentConfirmedState({required this.bookingDetails});
|
||||
}
|
||||
|
||||
/// 🆕 Payment Confirmation Error State
|
||||
class CheckoutPaymentConfirmationErrorState extends CheckoutState {
|
||||
final String error;
|
||||
|
||||
CheckoutPaymentConfirmationErrorState({required this.error});
|
||||
}
|
||||
102
lib/checkout/bloc/pass_purchase_details_bloc.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../profile/repository/profile_repository.dart';
|
||||
import '../repository/pass_purchase_details_repository.dart';
|
||||
import 'pass_purchase_details_event.dart';
|
||||
import 'pass_purchase_details_state.dart';
|
||||
|
||||
class PurchaseDetailsBloc
|
||||
extends Bloc<PassPurchaseDetailsEvent, PurchaseDetailsState> {
|
||||
final ProfileRepository _profileRepository;
|
||||
final PassPurchaseDetailsRepository _purchaseDetailsRepository;
|
||||
|
||||
PurchaseDetailsBloc({
|
||||
ProfileRepository? profileRepository,
|
||||
PassPurchaseDetailsRepository? purchaseDetailsRepository,
|
||||
}) : _profileRepository = profileRepository ?? ProfileRepository(),
|
||||
_purchaseDetailsRepository = purchaseDetailsRepository ?? PassPurchaseDetailsRepository(),
|
||||
super(PurchaseDetailsInitial()) {
|
||||
on<LoadProfileEvent>(_onLoadProfile);
|
||||
on<SetPurchaseDetailsEvent>(_onSetPurchaseDetails);
|
||||
on<ToggleGiftModeEvent>(_onToggleGiftMode);
|
||||
on<SubmitUserDetailsEvent>(_onSubmitUserDetails);
|
||||
}
|
||||
|
||||
Future<void> _onLoadProfile(
|
||||
LoadProfileEvent event,
|
||||
Emitter<PurchaseDetailsState> emit,
|
||||
) async {
|
||||
emit(PurchaseDetailsProfileLoading(isGift: state.isGift));
|
||||
|
||||
try {
|
||||
final profile = await _profileRepository.fetchUserProfile();
|
||||
emit(PurchaseDetailsLoaded(
|
||||
isGift: state.isGift,
|
||||
profile: profile,
|
||||
));
|
||||
} catch (e) {
|
||||
// Handle error - emit loaded state with null profile
|
||||
emit(PurchaseDetailsLoaded(
|
||||
isGift: state.isGift,
|
||||
profile: null,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSetPurchaseDetails(
|
||||
SetPurchaseDetailsEvent event,
|
||||
Emitter<PurchaseDetailsState> emit,
|
||||
) {
|
||||
final isGift = event.buyPassValue == "gift";
|
||||
emit(PurchaseDetailsUpdated(
|
||||
buyPassState: event.buyPassValue,
|
||||
isGift: isGift,
|
||||
profile: state.profile,
|
||||
));
|
||||
}
|
||||
|
||||
void _onToggleGiftMode(
|
||||
ToggleGiftModeEvent event,
|
||||
Emitter<PurchaseDetailsState> emit,
|
||||
) {
|
||||
emit(PurchaseDetailsLoaded(
|
||||
isGift: event.isGift,
|
||||
profile: state.profile,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _onSubmitUserDetails(
|
||||
SubmitUserDetailsEvent event,
|
||||
Emitter<PurchaseDetailsState> emit,
|
||||
) async {
|
||||
emit(PurchaseDetailsSubmitting(
|
||||
isGift: state.isGift,
|
||||
profile: state.profile,
|
||||
));
|
||||
|
||||
try {
|
||||
final response = await _purchaseDetailsRepository.submitUserDetails(
|
||||
bookingId: event.bookingId,
|
||||
isForSelf: event.isForSelf,
|
||||
recipientFirstName: event.recipientFirstName,
|
||||
recipientLastName: event.recipientLastName,
|
||||
recipientEmail: event.recipientEmail,
|
||||
recipientPhone: event.recipientPhone,
|
||||
city: event.city,
|
||||
country: event.country,
|
||||
);
|
||||
|
||||
emit(PurchaseDetailsSubmitted(
|
||||
response: response,
|
||||
isGift: state.isGift,
|
||||
profile: state.profile,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(PurchaseDetailsError(
|
||||
errorMessage: e.toString(),
|
||||
isGift: state.isGift,
|
||||
profile: state.profile,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
37
lib/checkout/bloc/pass_purchase_details_event.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
abstract class PassPurchaseDetailsEvent {}
|
||||
|
||||
class SetPurchaseDetailsEvent extends PassPurchaseDetailsEvent {
|
||||
final String buyPassValue; // "self" or "gift"
|
||||
|
||||
SetPurchaseDetailsEvent(this.buyPassValue);
|
||||
}
|
||||
|
||||
class LoadProfileEvent extends PassPurchaseDetailsEvent {}
|
||||
|
||||
class ToggleGiftModeEvent extends PassPurchaseDetailsEvent {
|
||||
final bool isGift;
|
||||
|
||||
ToggleGiftModeEvent(this.isGift);
|
||||
}
|
||||
|
||||
class SubmitUserDetailsEvent extends PassPurchaseDetailsEvent {
|
||||
final int bookingId;
|
||||
final bool isForSelf;
|
||||
final String? recipientFirstName;
|
||||
final String? recipientLastName;
|
||||
final String? recipientEmail;
|
||||
final String? recipientPhone;
|
||||
final String? city;
|
||||
final String? country;
|
||||
|
||||
SubmitUserDetailsEvent({
|
||||
required this.bookingId,
|
||||
required this.isForSelf,
|
||||
this.recipientFirstName,
|
||||
this.recipientLastName,
|
||||
this.recipientEmail,
|
||||
this.recipientPhone,
|
||||
this.city,
|
||||
this.country,
|
||||
});
|
||||
}
|
||||
93
lib/checkout/bloc/pass_purchase_details_state.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import '../../profile/models/profile_model.dart';
|
||||
|
||||
abstract class PurchaseDetailsState {
|
||||
final bool isGift;
|
||||
final ProfileModel? profile;
|
||||
final bool isLoadingProfile;
|
||||
final bool isSubmittingDetails;
|
||||
final String? errorMessage;
|
||||
|
||||
PurchaseDetailsState({
|
||||
this.isGift = false,
|
||||
this.profile,
|
||||
this.isLoadingProfile = false,
|
||||
this.isSubmittingDetails = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
class PurchaseDetailsInitial extends PurchaseDetailsState {
|
||||
PurchaseDetailsInitial() : super(isLoadingProfile: true);
|
||||
}
|
||||
|
||||
class PurchaseDetailsLoaded extends PurchaseDetailsState {
|
||||
PurchaseDetailsLoaded({
|
||||
required bool isGift,
|
||||
ProfileModel? profile,
|
||||
}) : super(
|
||||
isGift: isGift,
|
||||
profile: profile,
|
||||
isLoadingProfile: false,
|
||||
);
|
||||
}
|
||||
|
||||
class PurchaseDetailsUpdated extends PurchaseDetailsState {
|
||||
final String buyPassState; // "self" or "gift"
|
||||
|
||||
PurchaseDetailsUpdated({
|
||||
required this.buyPassState,
|
||||
required bool isGift,
|
||||
ProfileModel? profile,
|
||||
}) : super(
|
||||
isGift: isGift,
|
||||
profile: profile,
|
||||
isLoadingProfile: false,
|
||||
);
|
||||
}
|
||||
|
||||
class PurchaseDetailsProfileLoading extends PurchaseDetailsState {
|
||||
PurchaseDetailsProfileLoading({
|
||||
required bool isGift,
|
||||
}) : super(
|
||||
isGift: isGift,
|
||||
isLoadingProfile: true,
|
||||
);
|
||||
}
|
||||
|
||||
class PurchaseDetailsSubmitting extends PurchaseDetailsState {
|
||||
PurchaseDetailsSubmitting({
|
||||
required bool isGift,
|
||||
ProfileModel? profile,
|
||||
}) : super(
|
||||
isGift: isGift,
|
||||
profile: profile,
|
||||
isSubmittingDetails: true,
|
||||
);
|
||||
}
|
||||
|
||||
class PurchaseDetailsSubmitted extends PurchaseDetailsState {
|
||||
final Map<String, dynamic> response;
|
||||
|
||||
PurchaseDetailsSubmitted({
|
||||
required this.response,
|
||||
required bool isGift,
|
||||
ProfileModel? profile,
|
||||
}) : super(
|
||||
isGift: isGift,
|
||||
profile: profile,
|
||||
isSubmittingDetails: false,
|
||||
);
|
||||
}
|
||||
|
||||
class PurchaseDetailsError extends PurchaseDetailsState {
|
||||
PurchaseDetailsError({
|
||||
required String errorMessage,
|
||||
required bool isGift,
|
||||
ProfileModel? profile,
|
||||
}) : super(
|
||||
isGift: isGift,
|
||||
profile: profile,
|
||||
errorMessage: errorMessage,
|
||||
isSubmittingDetails: false,
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
abstract class PurchaseDetails {}
|
||||
|
||||
class SetPurchaseDetailsEvent extends PurchaseDetails {
|
||||
final String buyPassValue;
|
||||
|
||||
SetPurchaseDetailsEvent(this.buyPassValue);
|
||||
}
|
||||
|
||||
class PurchaseDetailsState {
|
||||
final String buyPassState;
|
||||
|
||||
PurchaseDetailsState(this.buyPassState);
|
||||
}
|
||||
|
||||
class PurchaseDetailsBloc
|
||||
extends Bloc<SetPurchaseDetailsEvent, PurchaseDetailsState> {
|
||||
PurchaseDetailsBloc() : super(PurchaseDetailsState("")) {
|
||||
on<SetPurchaseDetailsEvent>((event, emit){
|
||||
emit(PurchaseDetailsState(event.buyPassValue));
|
||||
});
|
||||
}
|
||||
}
|
||||
61
lib/checkout/models/all_coupons_model.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
class AllCouponsModel {
|
||||
final int id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final int cityXid;
|
||||
final int discountPercent;
|
||||
final String couponCode;
|
||||
final DateTime startDateTime;
|
||||
final DateTime endDateTime;
|
||||
final bool showAtCheckout;
|
||||
final String couponStatus;
|
||||
final bool isActive;
|
||||
|
||||
AllCouponsModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.cityXid,
|
||||
required this.discountPercent,
|
||||
required this.couponCode,
|
||||
required this.startDateTime,
|
||||
required this.endDateTime,
|
||||
required this.showAtCheckout,
|
||||
required this.couponStatus,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
/// From JSON
|
||||
factory AllCouponsModel.fromJson(Map<String, dynamic> json) {
|
||||
return AllCouponsModel(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String,
|
||||
description: json['description'],
|
||||
cityXid: json['cityXid'] as int,
|
||||
discountPercent: json['discountPercent'] as int,
|
||||
couponCode: json['couponCode'] as String,
|
||||
startDateTime: DateTime.parse(json['startDateTime']),
|
||||
endDateTime: DateTime.parse(json['endDateTime']),
|
||||
showAtCheckout: json['showAtCheckout'] as bool,
|
||||
couponStatus: json['couponStatus'] as String,
|
||||
isActive: json['isActive'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
/// To JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'cityXid': cityXid,
|
||||
'discountPercent': discountPercent,
|
||||
'couponCode': couponCode,
|
||||
'startDateTime': startDateTime.toIso8601String(),
|
||||
'endDateTime': endDateTime.toIso8601String(),
|
||||
'showAtCheckout': showAtCheckout,
|
||||
'couponStatus': couponStatus,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
lib/checkout/repository/all_coupons_repository.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:citycards_customer/localPreference/local_preference.dart';
|
||||
import '../models/all_coupons_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
|
||||
class AllCouponsRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
Future<List<AllCouponsModel>> fetchAllCoupons() async {
|
||||
final int cityXid = await LocalPreference.getSelectedCityId();
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.coupons}?cityXid=$cityXid',
|
||||
);
|
||||
final List<dynamic> data = response.data as List;
|
||||
return data.map((json) => AllCouponsModel.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
155
lib/checkout/repository/checkout_repository.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
|
||||
class CheckoutRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
/// 🆕 Initiate Payment - Hit the /pay API
|
||||
/// POST https://devapi.citycards.betadelivery.com/mobile/passes/{bookingId}/pay
|
||||
/// Returns: {"bookingId": 4, "clientSecret": "pi_xxx_secret_xxx"}
|
||||
Future<Map<String, dynamic>> initiatePayment({
|
||||
required int bookingId,
|
||||
}) async {
|
||||
try {
|
||||
log('🟢 initiatePayment() called');
|
||||
log('📤 [INITIATE PAYMENT] Booking ID: $bookingId');
|
||||
|
||||
// Construct URL with bookingId
|
||||
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/pay';
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [INITIATE PAYMENT] API URL: $url');
|
||||
}
|
||||
|
||||
// Send POST request
|
||||
final response = await _apiServices.postApi(
|
||||
url: url,
|
||||
data: {}, // Empty body, bookingId is in URL
|
||||
);
|
||||
|
||||
log('✅ [INITIATE PAYMENT] Response Status: ${response.statusCode}');
|
||||
log('📥 [INITIATE PAYMENT] Response Data: ${response.data}');
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [INITIATE PAYMENT] ✅ Payment initiation successful');
|
||||
print('📤 [INITIATE PAYMENT] Full Response: ${response.data}');
|
||||
}
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e, stackTrace) {
|
||||
log(
|
||||
'❌ initiatePayment FAILED',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw Exception('Failed to initiate payment: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 Confirm Payment after successful Stripe payment
|
||||
/// POST https://devapi.citycards.betadelivery.com/mobile/passes/{bookingId}/confirm-payment
|
||||
/// Body: {"stripeStatus": "succeeded", "paymentStatus": "success"}
|
||||
Future<Map<String, dynamic>> confirmPayment({
|
||||
required int bookingId,
|
||||
required String stripeStatus,
|
||||
required String paymentStatus,
|
||||
}) async {
|
||||
try {
|
||||
log('🟢 confirmPayment() called');
|
||||
log('📤 [CONFIRM PAYMENT] Booking ID: $bookingId');
|
||||
log('📤 [CONFIRM PAYMENT] Stripe Status: $stripeStatus');
|
||||
log('📤 [CONFIRM PAYMENT] Payment Status: $paymentStatus');
|
||||
|
||||
// Construct URL with bookingId
|
||||
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/confirm-payment';
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [CONFIRM PAYMENT] API URL: $url');
|
||||
}
|
||||
|
||||
// Request body
|
||||
final requestBody = {
|
||||
'stripeStatus': stripeStatus,
|
||||
'paymentStatus': paymentStatus,
|
||||
};
|
||||
|
||||
log('📦 Request Body: $requestBody');
|
||||
|
||||
// Send POST request
|
||||
final response = await _apiServices.postApi(
|
||||
url: url,
|
||||
data: requestBody,
|
||||
);
|
||||
|
||||
log('✅ [CONFIRM PAYMENT] Response Status: ${response.statusCode}');
|
||||
log('📥 [CONFIRM PAYMENT] Response Data: ${response.data}');
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [CONFIRM PAYMENT] ✅ Payment confirmation successful');
|
||||
print('📤 [CONFIRM PAYMENT] Full Response: ${response.data}');
|
||||
}
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e, stackTrace) {
|
||||
log(
|
||||
'❌ confirmPayment FAILED',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw Exception('Failed to confirm payment: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> applyCoupon({
|
||||
required int bookingId,
|
||||
required String couponCode,
|
||||
}) async {
|
||||
try {
|
||||
log('🟢 applyCoupon() called');
|
||||
log('📤 [APPLY COUPON] Booking ID: $bookingId');
|
||||
log('📤 [APPLY COUPON] Coupon Code: $couponCode');
|
||||
|
||||
// Construct API URL
|
||||
final url =
|
||||
'${ApiUrls.baseUrl}/mobile/passes/$bookingId/apply-coupon';
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [APPLY COUPON] API URL: $url');
|
||||
}
|
||||
|
||||
// Request body
|
||||
final requestBody = {
|
||||
'couponCode': couponCode,
|
||||
};
|
||||
|
||||
log('📦 Request Body: $requestBody');
|
||||
|
||||
// Send PUT request
|
||||
final response = await _apiServices.putApi(
|
||||
url: url,
|
||||
data: requestBody,
|
||||
);
|
||||
|
||||
log('✅ [APPLY COUPON] Response Status: ${response.statusCode}');
|
||||
log('📥 [APPLY COUPON] Response Data: ${response.data}');
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [APPLY COUPON] ✅ Coupon applied successfully');
|
||||
print('📤 [APPLY COUPON] Full Response: ${response.data}');
|
||||
}
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e, stackTrace) {
|
||||
log(
|
||||
'❌ applyCoupon FAILED',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw Exception('Failed to apply coupon: $e');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
|
||||
class PassPurchaseDetailsRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
/// Submit user details for pass purchase
|
||||
/// POST https://devapi.citycards.betadelivery.com/mobile/passes/{bookingId}/user-details
|
||||
Future<Map<String, dynamic>> submitUserDetails({
|
||||
required int bookingId,
|
||||
required bool isForSelf,
|
||||
String? recipientFirstName,
|
||||
String? recipientLastName,
|
||||
String? recipientEmail,
|
||||
String? recipientPhone,
|
||||
String? city,
|
||||
String? country,
|
||||
}) async {
|
||||
try {
|
||||
log('🟢 submitUserDetails() called');
|
||||
log('📤 [SUBMIT USER DETAILS] Booking ID: $bookingId');
|
||||
log('📤 [SUBMIT USER DETAILS] Is For Self: $isForSelf');
|
||||
|
||||
// Construct URL with bookingId
|
||||
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/user-details';
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [SUBMIT USER DETAILS] API URL: $url');
|
||||
}
|
||||
|
||||
// Request body
|
||||
final requestBody = {
|
||||
'isForSelf': isForSelf,
|
||||
'recipientFirstName': recipientFirstName ?? '',
|
||||
'recipientLastName': recipientLastName ?? '',
|
||||
'recipientEmail': recipientEmail ?? '',
|
||||
'recipientPhone': recipientPhone ?? '',
|
||||
'recipientCity': city ?? '',
|
||||
'recipientCountry': country ?? '',
|
||||
};
|
||||
|
||||
log('📦 Request Body: $requestBody');
|
||||
|
||||
// Send POST request
|
||||
final response = await _apiServices.putApi(
|
||||
url: url,
|
||||
data: requestBody,
|
||||
);
|
||||
|
||||
log('✅ [SUBMIT USER DETAILS] Response Status: ${response.statusCode}');
|
||||
log('📥 [SUBMIT USER DETAILS] Response Data: ${response.data}');
|
||||
|
||||
if (kDebugMode) {
|
||||
print('📤 [SUBMIT USER DETAILS] ✅ User details submission successful');
|
||||
print('📤 [SUBMIT USER DETAILS] Full Response: ${response.data}');
|
||||
}
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e, stackTrace) {
|
||||
log(
|
||||
'❌ submitUserDetails FAILED',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw Exception('Failed to submit user details: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +1,174 @@
|
||||
import 'package:citycards_customer/postcard/widgets/purchase_details_bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import '../bloc/allCoupons/all_coupons_bloc.dart';
|
||||
import '../bloc/allCoupons/all_coupons_event.dart';
|
||||
import '../bloc/allCoupons/all_coupons_state.dart';
|
||||
import '../repository/all_coupons_repository.dart';
|
||||
|
||||
class AllCouponsBottomsheet extends StatelessWidget {
|
||||
AllCouponsBottomsheet({super.key});
|
||||
final Function(dynamic coupon)? onCouponSelected;
|
||||
|
||||
final List<Map<String, String>> coupons = [
|
||||
{
|
||||
"text": "Flat 3% cashback using Amazon Pay Balance",
|
||||
"coupon_code": "AMZNPAY3",
|
||||
},
|
||||
{
|
||||
"text": "Flat 3% cashback using Amazon Pay Balance",
|
||||
"coupon_code": "AMZNPAY3",
|
||||
},
|
||||
{
|
||||
"text": "Flat 3% cashback using Amazon Pay Balance",
|
||||
"coupon_code": "AMZNPAY3",
|
||||
},
|
||||
{
|
||||
"text": "Flat 3% cashback using Amazon Pay Balance",
|
||||
"coupon_code": "AMZNPAY3",
|
||||
},
|
||||
];
|
||||
const AllCouponsBottomsheet({
|
||||
super.key,
|
||||
this.onCouponSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
top: 24.h,
|
||||
left: 20.w,
|
||||
right: 20.w,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
/// --- Header ---
|
||||
Container(
|
||||
height: 4.h,
|
||||
width: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF2D3134),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
return BlocProvider(
|
||||
create: (context) => AllCouponsBloc(repository: AllCouponsRepository())
|
||||
..add(FetchAllCouponsEvent()),
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
top: 24.h,
|
||||
left: 20.w,
|
||||
right: 20.w,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
/// --- Header ---
|
||||
Container(
|
||||
height: 4.h,
|
||||
width: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF2D3134),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
CustomText(text: "All Coupons", size: 18.sp, weight: FontWeight.w500),
|
||||
SizedBox(height: 22.h),
|
||||
SizedBox(height: 12.h),
|
||||
CustomText(
|
||||
text: "All Coupons", size: 18.sp, weight: FontWeight.w500),
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
/// --- Coupon list ---
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: coupons.length,
|
||||
separatorBuilder: (_, __) => SizedBox(height: 12.h),
|
||||
itemBuilder: (context, index) {
|
||||
final coupon = coupons[index];
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFF95F62).withOpacity(0.12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 220.w,
|
||||
child: CustomText(
|
||||
text: coupon['text'] ?? "",
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
/// --- Coupon list ---
|
||||
Flexible(
|
||||
child: BlocBuilder<AllCouponsBloc, AllCouponsState>(
|
||||
builder: (context, state) {
|
||||
if (state is CouponsLoadingState) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
);
|
||||
} else if (state is CouponsErrorState) {
|
||||
return Center(
|
||||
child: CustomText(
|
||||
text: "Error: ${state.error}",
|
||||
size: 14.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
);
|
||||
} else if (state is CouponsLoadedState) {
|
||||
if (state.coupons.isEmpty) {
|
||||
return Center(
|
||||
child: CustomText(
|
||||
text: "No coupons available",
|
||||
size: 14.sp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: state.coupons.length,
|
||||
separatorBuilder: (_, __) => SizedBox(height: 12.h),
|
||||
itemBuilder: (context, index) {
|
||||
final coupon = state.coupons[index];
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8.w, vertical: 8.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFF95F62).withOpacity(0.12),
|
||||
),
|
||||
),
|
||||
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
PurchaseDetailsBottomSheet.show(context);
|
||||
},
|
||||
child: Container(
|
||||
width: 110.w,
|
||||
height: 44.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 220.w,
|
||||
child: CustomText(
|
||||
text: "${coupon.discountPercent}% discount on ${coupon.title}",
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// Pass the selected coupon back to checkout view
|
||||
if (onCouponSelected != null) {
|
||||
onCouponSelected!(coupon);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
width: 110.w,
|
||||
height: 44.h,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Center(
|
||||
child: CustomText(
|
||||
text: "Apply Coupon",
|
||||
size: 12.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: CustomText(
|
||||
text: "Apply Coupon",
|
||||
size: 12.sp,
|
||||
color: Colors.white,
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
height: 32.h,
|
||||
width: 83.w,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Color(0xFFF95F62).withOpacity(0.12),
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Center(
|
||||
child: CustomText(
|
||||
text: coupon.couponCode,
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
height: 32.h,
|
||||
width: 83.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62).withOpacity(0.12),
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Center(
|
||||
child: CustomText(
|
||||
text: coupon['coupon_code'] ?? "",
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
color: Color(0xFFF95F62),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
return SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import 'package:citycards_customer/checkout/widget/verify_otp_bottomsheet.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class LoginEmailBottomsheet extends StatelessWidget {
|
||||
const LoginEmailBottomsheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
top: 24.h,
|
||||
left: 20.h,
|
||||
right: 20.h,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // shrink to fit content
|
||||
children: [
|
||||
Image.asset("assets/logo/logo_city_cards_orange.png", scale: 4),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(text: "Get Started", size: 18.sp, weight: FontWeight.w500),
|
||||
SizedBox(height: 42.h),
|
||||
CustomText(
|
||||
text: "Enter your email to begin your CityCards journey",
|
||||
size: 14.sp,
|
||||
color: const Color(0xFF000000).withOpacity(.6),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
fillColor: const Color(0xFFFFF5F5),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: const Color(0xFFBB474A), width: 0.4.w),
|
||||
borderRadius: BorderRadius.circular(8.sp),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: const Color(0xFFBB474A), width: 0.4.w),
|
||||
borderRadius: BorderRadius.circular(8.sp),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.email_outlined, color: Color(0xFFF95F62)),
|
||||
hintText: "john.doe@gmail.com",
|
||||
hintStyle: TextStyle(
|
||||
color: const Color(0xFF000000).withOpacity(0.6),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 38.h),
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
isScrollControlled: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => VerifyOtpBottomsheet(),
|
||||
);
|
||||
},
|
||||
label: "Continue",
|
||||
width: double.infinity,
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
InkWell(
|
||||
onTap: (){
|
||||
Navigator.of(context).pushNamed(RouteConstants.createAcct);
|
||||
},
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "Already have an account?",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " Sign in",
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFF95F62),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
300
lib/checkout/widget/pass_purchase_details_bottomsheet.dart
Normal file
@@ -0,0 +1,300 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../add_details/add_details_view.dart';
|
||||
import '../../profile/repository/profile_repository.dart';
|
||||
import '../../profile/view/edit_profile/edit_profile_view.dart';
|
||||
import '../bloc/pass_purchase_details_bloc.dart';
|
||||
import '../bloc/pass_purchase_details_event.dart';
|
||||
import '../bloc/pass_purchase_details_state.dart';
|
||||
|
||||
class PassPurchaseBottomSheet {
|
||||
static Future<String?> show(BuildContext context, {required int bookingId}) async {
|
||||
return await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => PurchaseDetailsBloc()..add(LoadProfileEvent()),
|
||||
child: _PassPurchaseContent(bookingId: bookingId),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static void close(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
class _PassPurchaseContent extends StatelessWidget {
|
||||
final int bookingId;
|
||||
|
||||
const _PassPurchaseContent({required this.bookingId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<PurchaseDetailsBloc, PurchaseDetailsState>(
|
||||
listener: (context, state) {
|
||||
// Handle API submission success
|
||||
if (state is PurchaseDetailsSubmitted) {
|
||||
// Close bottom sheet and return success
|
||||
Navigator.of(context).pop('success');
|
||||
|
||||
// Show success message
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// const SnackBar(
|
||||
// content: Text('Details submitted successfully!'),
|
||||
// backgroundColor: Color(0xffF95F62),
|
||||
// ),
|
||||
// );
|
||||
}
|
||||
|
||||
// Handle API submission error
|
||||
if (state is PurchaseDetailsError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Failed to submit details'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 45,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[400],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Text(
|
||||
"Purchase Details",
|
||||
style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
/// BUY FOR MYSELF
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PurchaseDetailsBloc>().add(ToggleGiftModeEvent(false));
|
||||
context.read<PurchaseDetailsBloc>().add(SetPurchaseDetailsEvent("self"));
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: !state.isGift
|
||||
? Border.all(color: const Color(0xffF95F62), width: 1.5)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Radio<bool>(
|
||||
value: false,
|
||||
groupValue: state.isGift,
|
||||
onChanged: (_) {},
|
||||
activeColor: const Color(0xffF95F62),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Buy Pass for Myself",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: !state.isGift
|
||||
? const Color(0xffF95F62)
|
||||
: const Color(0xff9E9E9E),
|
||||
),
|
||||
),
|
||||
if (!state.isGift && state.profile != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"${state.profile!.firstName} ${state.profile!.lastName}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${state.profile!.address1 ?? ""}\n${state.profile!.address2 ?? ""}",
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xff5E5E5E),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!state.isGift && state.isLoadingProfile) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!state.isGift)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
PassPurchaseBottomSheet.close(context);
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const EditProfilePage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xffF95F62),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
"Edit Details",
|
||||
style: TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
/// GIFT PASS
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<PurchaseDetailsBloc>().add(ToggleGiftModeEvent(true));
|
||||
context.read<PurchaseDetailsBloc>().add(SetPurchaseDetailsEvent("gift"));
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: state.isGift
|
||||
? Border.all(color: const Color(0xffF95F62), width: 1.5)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Radio<bool>(
|
||||
value: true,
|
||||
groupValue: state.isGift,
|
||||
onChanged: (_) {},
|
||||
activeColor: const Color(0xffF95F62),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text(
|
||||
"Gift the pass",
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
"Gift the pass for someone else",
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: Color(0xff9E9E9E)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 15),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: state.isSubmittingDetails
|
||||
? null
|
||||
: () {
|
||||
if (state.isGift) {
|
||||
// ✅ Just close bottom sheet and return 'gift'
|
||||
// Let checkout view handle the navigation
|
||||
Navigator.of(context).pop('gift');
|
||||
} else {
|
||||
// Submit user details for "Buy for Myself"
|
||||
if (state.profile != null) {
|
||||
context.read<PurchaseDetailsBloc>().add(
|
||||
SubmitUserDetailsEvent(
|
||||
bookingId: bookingId,
|
||||
isForSelf: true,
|
||||
recipientFirstName: state.profile!.firstName,
|
||||
recipientLastName: state.profile!.lastName,
|
||||
recipientEmail: state.profile!.emailAddress,
|
||||
recipientPhone: state.profile!.mobileNumber,
|
||||
city: '', // Empty for self
|
||||
country: '', // Empty for self
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xffF95F62),
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
),
|
||||
child: state.isSubmittingDetails
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
"Proceed",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../core/route_constants.dart';
|
||||
|
||||
class VerifyOtpBottomsheet extends StatelessWidget {
|
||||
VerifyOtpBottomsheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
top: 24.h,
|
||||
left: 20.h,
|
||||
right: 20.h,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min, // shrink to fit content
|
||||
children: [
|
||||
Image.asset("assets/logo/logo_city_cards_orange.png", scale: 4),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: "Verify your phone",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
SizedBox(height: 42.h),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "Enter the verification code sent to your email id",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " frank7824@mail.com",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
|
||||
OtpTextField(
|
||||
numberOfFields: 6,
|
||||
borderWidth: 0.4.w,
|
||||
fieldWidth: 48.w,
|
||||
fieldHeight: 60.h,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFFF5F5),
|
||||
borderColor: const Color(0xFFBB474A),
|
||||
cursorColor: const Color(0xFFF95F62),
|
||||
showFieldAsBox: true,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
onCodeChanged: (code) {},
|
||||
onSubmit: (code) {
|
||||
debugPrint("OTP entered: $code");
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(height: 42.h),
|
||||
CustomFilledButton(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
label: "Continue",
|
||||
width: double.infinity,
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(RouteConstants.createAcct);
|
||||
},
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "Already have an account?",
|
||||
style: TextStyle(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " Sign in",
|
||||
style: TextStyle(
|
||||
color: const Color(0xFFF95F62),
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../core/route_constants.dart';
|
||||
import '../home/widgets/search_city_bottomsheet.dart';
|
||||
import '../localPreference/local_preference.dart';
|
||||
import '../profile/bloc/profile/profile_bloc.dart';
|
||||
import '../profile/bloc/profile/profile_state.dart';
|
||||
|
||||
class CommonAppBar extends StatelessWidget {
|
||||
const CommonAppBar({
|
||||
@@ -10,64 +15,103 @@ class CommonAppBar extends StatelessWidget {
|
||||
required this.isWhiteLogo,
|
||||
required this.isProfilePage,
|
||||
this.showCart = true,
|
||||
required this.showDivider
|
||||
required this.showDivider,
|
||||
this.imageUrl,
|
||||
this.isSelectCity = false,
|
||||
});
|
||||
|
||||
final bool isWhiteLogo;
|
||||
final bool isProfilePage;
|
||||
final bool? showCart;
|
||||
final bool showDivider;
|
||||
final String? imageUrl;
|
||||
final bool isSelectCity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPathIcon =
|
||||
imageUrl != null && imageUrl!.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
/// LEFT SIDE
|
||||
Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/melbourne_white.png"
|
||||
: "assets/logo/melbourne_logo.png",
|
||||
scale: 4,
|
||||
/// ✅ LOGO / PATH ICON (SIZE CONTROLLED)
|
||||
SizedBox(
|
||||
height: isPathIcon ? 40.h : 32.h, // 🔥 ONLY path icon bigger
|
||||
child: isPathIcon
|
||||
? Image.network(
|
||||
imageUrl!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/logo_city_cards_white.png"
|
||||
: "assets/logo/logo_city_cards.png",
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.asset(
|
||||
isWhiteLogo
|
||||
? "assets/logo/logo_city_cards_white.png"
|
||||
: "assets/logo/logo_city_cards.png",
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
IconButton(onPressed: (){
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const CitySelectionBottomSheet(),
|
||||
|
||||
);
|
||||
|
||||
}, icon: Icon(Icons.arrow_drop_down, color: isWhiteLogo ? Colors.white : Color(0xffF95F62), size: 30,))
|
||||
/// ✅ CITY DROPDOWN
|
||||
if (isSelectCity)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const CitySelectionBottomSheet(),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: isWhiteLogo
|
||||
? Colors.white
|
||||
: const Color(0xffF95F62),
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
/// RIGHT SIDE
|
||||
Row(
|
||||
children: [
|
||||
if(showCart!)
|
||||
InkWell(
|
||||
onTap: (){
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pushNamed(RouteConstants.cartPage);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
"assets/icons/shopping_cart.png",
|
||||
height: 20.h,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showCart!)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pushNamed(RouteConstants.cartPage);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image.asset(
|
||||
"assets/icons/shopping_cart.png",
|
||||
height: 20.h,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 8.w),
|
||||
|
||||
if (!isProfilePage)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
@@ -76,20 +120,51 @@ class CommonAppBar extends StatelessWidget {
|
||||
rootNavigator: true,
|
||||
).pushNamed(RouteConstants.profile);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Color(0xffFFDFDF),
|
||||
child: Image.asset( "assets/images/profile_default_img.png",),
|
||||
child: BlocBuilder<ProfileBloc, ProfileState>(
|
||||
builder: (context, state) {
|
||||
String? imagePath;
|
||||
|
||||
// ✅ Get image from profile state
|
||||
if (state is ProfileLoaded) {
|
||||
imagePath = state.profile.profileImage;
|
||||
}
|
||||
|
||||
// ✅ Build full image URL
|
||||
final String? imageUrl =
|
||||
(imagePath != null && imagePath.isNotEmpty)
|
||||
? "${ApiUrls.baseUrl}$imagePath"
|
||||
: null;
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 20.r,
|
||||
backgroundColor: const Color(0xffFFDFDF),
|
||||
|
||||
// ✅ Network image only if exists
|
||||
backgroundImage:
|
||||
(imageUrl != null && imageUrl.isNotEmpty)
|
||||
? NetworkImage(imageUrl)
|
||||
: null,
|
||||
|
||||
// ✅ Default fallback (unchanged)
|
||||
child: (imageUrl == null || imageUrl.isEmpty)
|
||||
? Image.asset(
|
||||
"assets/images/profile_default_img.png",
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
/// DIVIDER
|
||||
if (showDivider)
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 12.h),
|
||||
Divider(height: 1.h, color: Color(0xFFD9D9D9)),
|
||||
const Divider(height: 1, color: Color(0xFFD9D9D9)),
|
||||
SizedBox(height: 22.h),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class CommonAppText {
|
||||
static const String selectiveCard = "Selective";
|
||||
static const String selectiveCard = "Flexi";
|
||||
}
|
||||
49
lib/common_packages/custom_dash_border_painter.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DashedBorderPainter extends CustomPainter {
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final double gap;
|
||||
final double dashWidth;
|
||||
final double radius;
|
||||
|
||||
DashedBorderPainter({
|
||||
required this.color,
|
||||
this.strokeWidth = 1.5,
|
||||
this.gap = 6,
|
||||
this.dashWidth = 6,
|
||||
this.radius = 16,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final rRect = RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
Radius.circular(radius),
|
||||
);
|
||||
|
||||
final path = Path()..addRRect(rRect);
|
||||
|
||||
final dashPath = Path();
|
||||
for (final metric in path.computeMetrics()) {
|
||||
double distance = 0;
|
||||
while (distance < metric.length) {
|
||||
dashPath.addPath(
|
||||
metric.extractPath(distance, distance + dashWidth),
|
||||
Offset.zero,
|
||||
);
|
||||
distance += dashWidth + gap;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(dashPath, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
210
lib/common_packages/custom_snackbar.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class CustomSnackbar {
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
IconData? icon,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
if (useOverlay) {
|
||||
_showOverlaySnackbar(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: backgroundColor ?? Colors.black87,
|
||||
textColor: textColor ?? Colors.white,
|
||||
icon: icon,
|
||||
duration: duration,
|
||||
);
|
||||
} else {
|
||||
_showRegularSnackbar(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: backgroundColor ?? Colors.black87,
|
||||
textColor: textColor ?? Colors.white,
|
||||
icon: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void _showRegularSnackbar(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
IconData? icon,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: textColor,
|
||||
size: 20.sp,
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: backgroundColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showOverlaySnackbar(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
required Color backgroundColor,
|
||||
required Color textColor,
|
||||
IconData? icon,
|
||||
required Duration duration,
|
||||
}) {
|
||||
final overlay = Overlay.of(context);
|
||||
final overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 10,
|
||||
left: 20.w,
|
||||
right: 20.w,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, -20 * (1 - value)),
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: textColor,
|
||||
size: 20.sp,
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(overlayEntry);
|
||||
Future.delayed(duration, () {
|
||||
overlayEntry.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Helper methods for common use cases
|
||||
static void showSuccess(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.green,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.check_circle,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
static void showError(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.red,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.error,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
static void showWarning(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.orange,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.warning,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
static void showInfo(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
bool useOverlay = false,
|
||||
}) {
|
||||
show(
|
||||
context,
|
||||
message: message,
|
||||
backgroundColor: Colors.blue,
|
||||
textColor: Colors.white,
|
||||
icon: Icons.info,
|
||||
useOverlay: useOverlay,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomText extends StatelessWidget {
|
||||
@@ -8,6 +7,7 @@ class CustomText extends StatelessWidget {
|
||||
final String text;
|
||||
final int? maxLines;
|
||||
final TextOverflow? overflow;
|
||||
final TextAlign? textAlign;
|
||||
|
||||
const CustomText({
|
||||
Key? key,
|
||||
@@ -17,6 +17,7 @@ class CustomText extends StatelessWidget {
|
||||
required this.text,
|
||||
this.maxLines,
|
||||
this.overflow,
|
||||
this.textAlign,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -37,7 +38,7 @@ class CustomText extends StatelessWidget {
|
||||
),
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
textAlign: textAlign,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final int? maxLines;
|
||||
final bool enabled;
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final Widget? suffixIcon;
|
||||
final void Function(String)? onChanged;
|
||||
|
||||
// ✅ NEW
|
||||
final int? maxLength; // e.g. 10
|
||||
final bool numbersOnly; // allow only digits
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
@@ -14,6 +25,16 @@ class CustomTextField extends StatelessWidget {
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
this.maxLines = 1,
|
||||
this.enabled = true,
|
||||
this.validator,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.suffixIcon,
|
||||
this.onChanged,
|
||||
|
||||
// ✅ NEW
|
||||
this.maxLength, // default = null (infinite)
|
||||
this.numbersOnly = false, // default = false
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -23,33 +44,79 @@ class CustomTextField extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: label, size: 14.sp),
|
||||
CustomText(
|
||||
text: label,
|
||||
size: 14.sp,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
SizedBox(
|
||||
height: maxLines == 1 ? 42.h : null,
|
||||
child: TextField(
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
maxLines: obscureText ? 1 : maxLines,
|
||||
enabled: enabled,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
|
||||
// ✅ NEW
|
||||
maxLength: maxLength,
|
||||
inputFormatters: [
|
||||
if (numbersOnly)
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
if (maxLength != null)
|
||||
LengthLimitingTextInputFormatter(maxLength),
|
||||
],
|
||||
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(fontSize: 12.sp, color: Color(0xFF8E8E8E)),
|
||||
counterText: "", // ✅ hides 0/10 counter
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF8E8E8E),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFFF5F5),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
fillColor: enabled
|
||||
? const Color(0xFFFFF5F5)
|
||||
: Colors.grey.shade200,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24.w,
|
||||
vertical: maxLines != null && maxLines! > 1 ? 12.h : 0,
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Color(0xBBC83B61).withOpacity(0.4),
|
||||
color: const Color(0xBBC83B61).withOpacity(0.4),
|
||||
width: .4.w,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Color(0xFFF95F62),
|
||||
color: const Color(0xFFF95F62),
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1.5.w,
|
||||
),
|
||||
),
|
||||
errorStyle: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -57,4 +124,4 @@ class CustomTextField extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/back_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_textfield.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class ContactUsPage extends StatelessWidget {
|
||||
const ContactUsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TextEditingController firstNameController = TextEditingController();
|
||||
final TextEditingController lastNameController = TextEditingController();
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController messageController = TextEditingController();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header bar
|
||||
CommonAppBar(isWhiteLogo: false, isProfilePage: true, showDivider: true,),
|
||||
|
||||
backWidget(context,"Contact Us", Colors.black),
|
||||
SizedBox(height: 22.h),
|
||||
|
||||
CustomText(
|
||||
text:
|
||||
"You can get in touch with us through the below platforms. Our team will contact you shortly",
|
||||
size: 14.sp,
|
||||
color: Colors.black.withOpacity(.6),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// Customer Support Section
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0x00000005).withOpacity(.02),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Customer Support",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_supportBox(
|
||||
icon: Icons.phone,
|
||||
title: "Contact Number",
|
||||
subtitle: "+1012 3456 789",
|
||||
action: "Tap to call",
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
_supportBox(
|
||||
icon: Icons.email_rounded,
|
||||
title: "Email",
|
||||
subtitle: "citycards24@gmail.com",
|
||||
action: "Tap to email",
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
_supportBox(
|
||||
icon: Icons.location_on,
|
||||
title: "Location",
|
||||
subtitle:
|
||||
"132 Dartmouth Street Boston, Massachusetts 02156 United States",
|
||||
action: "View on map",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Text fields
|
||||
CustomTextField(
|
||||
label: "First Name",
|
||||
hint: "Enter your first name",
|
||||
controller: firstNameController,
|
||||
),
|
||||
CustomTextField(
|
||||
label: "Last Name",
|
||||
hint: "Enter your last name",
|
||||
controller: lastNameController,
|
||||
),
|
||||
CustomTextField(
|
||||
label: "Email",
|
||||
hint: "Enter your email address",
|
||||
controller: emailController,
|
||||
),
|
||||
CustomTextField(
|
||||
label: "Phone Number",
|
||||
hint: "Enter your phone number",
|
||||
controller: phoneController,
|
||||
),
|
||||
|
||||
CustomTextField(
|
||||
label: "Description",
|
||||
hint: "Write your message here",
|
||||
maxLines: 4,
|
||||
controller: messageController,
|
||||
),
|
||||
|
||||
// _descriptionField(messageController),
|
||||
SizedBox(height: 24.h),
|
||||
|
||||
// Submit Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFF95F62),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(38.r),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
),
|
||||
onPressed: () {},
|
||||
child: CustomText(
|
||||
text: "Submit Ticket",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Support Info Box ---
|
||||
Widget _supportBox({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required String action,
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: const Color(0xFFF95F62), width: 0.8),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: const Color(0xFFF95F62), size: 32.sp),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: title,
|
||||
size: 11.sp,
|
||||
weight: FontWeight.w600,
|
||||
color: Color(0x00000000).withOpacity(.6),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
action,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: Color(0xFF000000).withOpacity(.4),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Description Field ---
|
||||
Widget _descriptionField(TextEditingController controller) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: "Description", size: 14.sp),
|
||||
SizedBox(height: 6.h),
|
||||
TextField(
|
||||
controller: controller,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Write your message here",
|
||||
hintStyle: TextStyle(fontSize: 12.sp, color: Color(0xFF8E8E8E)),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFFF5F5),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: const Color(0xBBC83B61).withOpacity(0.4),
|
||||
width: .4.w,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(color: Color(0xFFF95F62), width: 1.w),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:citycards_customer/Profile/profile_page_view.dart';
|
||||
|
||||
import 'package:citycards_customer/add_details/add_details_view.dart';
|
||||
import 'package:citycards_customer/attraction_details/attraction_details_view.dart';
|
||||
import 'package:citycards_customer/attraction_details/views/attraction_details_view.dart';
|
||||
import 'package:citycards_customer/attractions/models/attraction_model.dart';
|
||||
import 'package:citycards_customer/buy_a_pass/view/buy_pass_view.dart';
|
||||
import 'package:citycards_customer/checkout/view/checkout_view.dart';
|
||||
import 'package:citycards_customer/common_bloc/language_selection_bloc.dart';
|
||||
import 'package:citycards_customer/contact_us/contact_us_view.dart';
|
||||
import 'package:citycards_customer/create_account/create_account_view.dart';
|
||||
import 'package:citycards_customer/edit_profile/edit_profile_view.dart';
|
||||
import 'package:citycards_customer/create_account/view/create_account_view.dart';
|
||||
import 'package:citycards_customer/esim_offer/esim_offer_view.dart';
|
||||
import 'package:citycards_customer/faq/faq_view.dart';
|
||||
import 'package:citycards_customer/hotel_offer/hotel_offer_view.dart';
|
||||
import 'package:citycards_customer/intro_screens/views/intro_screen_view.dart';
|
||||
import 'package:citycards_customer/itinerary_creation/bloc/itinerary_detail_bloc.dart';
|
||||
@@ -16,13 +14,13 @@ import 'package:citycards_customer/itinerary_creation/bloc/itinerary_steps_selec
|
||||
import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_start_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_filled_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/privacy/privacy_view.dart';
|
||||
import 'package:citycards_customer/search_offers/bloc/search_offers_listing_bloc.dart';
|
||||
import 'package:citycards_customer/search_offers/view/search_offers_with_listing.dart';
|
||||
import 'package:citycards_customer/splash_screen/views/splash_screen.dart';
|
||||
import 'package:citycards_customer/terms_and_condition/terms_and_condition_view.dart';
|
||||
import 'package:citycards_customer/trail.dart';
|
||||
import 'package:citycards_customer/your_itinerary/view/your_itinerary_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -32,6 +30,19 @@ import '../cart/views/my_cart_view_page.dart';
|
||||
import '../common_bloc/bottom_navigation_bloc.dart';
|
||||
import '../home/views/home_page_view.dart';
|
||||
import '../home/views/registered_user_home_page.dart';
|
||||
import '../my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart';
|
||||
import '../my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart';
|
||||
import '../my_pass/repository/my_passes_attractions_repository.dart';
|
||||
import '../my_pass/repository/my_passes_offers_repository.dart';
|
||||
import '../my_pass/views/pass_attraction_details_view.dart';
|
||||
import '../profile/view/contact_us/contact_us_view.dart';
|
||||
import '../profile/view/edit_profile/edit_profile_view.dart';
|
||||
import '../profile/view/faq/faq_view.dart';
|
||||
import '../profile/view/privacy/privacy_view.dart';
|
||||
import '../profile/view/profile_page_view.dart';
|
||||
import '../profile/view/terms_and_condition/terms_and_condition_view.dart';
|
||||
import '../search_offers/bloc/offers_bloc.dart';
|
||||
import '../search_offers/repository/offers_repository.dart';
|
||||
import 'route_constants.dart';
|
||||
|
||||
class AppRouter {
|
||||
@@ -66,6 +77,24 @@ class AppRouter {
|
||||
case RouteConstants.attractionsPage:
|
||||
final args = settings.arguments as String;
|
||||
return MaterialPageRoute(builder: (_) => AttractionsPage(source: args));
|
||||
case RouteConstants.passAttractionsPage:
|
||||
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
|
||||
final int cityId = args['cityId'] as int;
|
||||
final String source = args['source'] as String;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => MyPassesAttractionsBloc(
|
||||
repository: MyPassesAttractionsRepository(),
|
||||
),
|
||||
child: PassAttractionsPage(
|
||||
cityXid: cityId,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
case RouteConstants.profile:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
@@ -146,9 +175,18 @@ class AppRouter {
|
||||
);
|
||||
|
||||
case RouteConstants.attractionDetails:
|
||||
final attractionId = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AttractionDetailsView();
|
||||
return AttractionDetailsView(attractionId: attractionId);
|
||||
},
|
||||
);
|
||||
|
||||
case RouteConstants.passAttractionDetails:
|
||||
final attractionID = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AttractionDetailsView(attractionId: attractionID);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -160,12 +198,13 @@ class AppRouter {
|
||||
);
|
||||
|
||||
case RouteConstants.checkout:
|
||||
final bookingId = settings.arguments as int; // or String
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return CheckoutView();
|
||||
},
|
||||
builder: (_) => CheckoutView(bookingId: bookingId),
|
||||
);
|
||||
|
||||
|
||||
case RouteConstants.cartPage:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
@@ -177,23 +216,40 @@ class AppRouter {
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => OffersBloc(),
|
||||
child: SearchOffersWithListing(),
|
||||
create: (_) => OffersBloc(OffersRepository()),
|
||||
child: OffersScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
case RouteConstants.searchPassOffer:
|
||||
final int cityId = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository()),
|
||||
child: PassOffersScreen(cityId: cityId),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
case RouteConstants.addDetails:
|
||||
final bookingId = settings.arguments as int;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AddDetailsView();
|
||||
return AddDetailsView(bookingId: bookingId);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
case RouteConstants.createAcct:
|
||||
final email = settings.arguments as String;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return CreateAccountView();
|
||||
return CreateAccountView(
|
||||
email: email, // ✅ required param
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -214,17 +270,20 @@ class AppRouter {
|
||||
case RouteConstants.magicItineraryFilledScreen:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return MagicItineraryFilledView();
|
||||
return MagicItineraryView();
|
||||
},
|
||||
);
|
||||
|
||||
case RouteConstants.offerPassDetail:
|
||||
final offerId = settings.arguments as int;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return OfferPassDetailView();
|
||||
},
|
||||
builder: (_) => OffersDetailsView(
|
||||
offerId: offerId,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
case RouteConstants.registeredUserHome:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
|
||||
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>();
|
||||
}
|
||||
@@ -1,27 +1,40 @@
|
||||
import 'package:citycards_customer/attractions/models/attraction_model.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:citycards_customer/home/views/registered_user_home_page.dart';
|
||||
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
|
||||
import 'package:citycards_customer/my_pass/views/pass_attraction_details_view.dart';
|
||||
import 'package:citycards_customer/my_pass/views/pass_attractions_page_view.dart';
|
||||
import 'package:citycards_customer/my_pass/views/search_pass_offers_with_listing.dart';
|
||||
import 'package:citycards_customer/postcard/views/add_filter_step_page_view.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../attraction_details/attraction_details_view.dart';
|
||||
import '../attraction_details/views/attraction_details_view.dart';
|
||||
import '../attractions/views/attractions_page_view.dart';
|
||||
import '../buy_a_pass/view/buy_pass_view.dart';
|
||||
import '../checkout/view/checkout_view.dart';
|
||||
import '../create_account/create_account_view.dart';
|
||||
import '../create_account/view/create_account_view.dart';
|
||||
import '../intro_screens/views/intro_screen_view.dart';
|
||||
import '../itinerary_creation/bloc/itinerary_detail_bloc.dart';
|
||||
import '../itinerary_creation/bloc/itinerary_steps_selection_bloc.dart';
|
||||
import '../itinerary_creation/views/itinerary_creation_view.dart';
|
||||
import '../itinerary_creation/views/magic_itinerary_filled_view.dart';
|
||||
import '../itinerary_creation/views/magic_itinerary_view.dart';
|
||||
import '../my_pass/blocs/myPassesAttrctions/my_passes_attractions_bloc.dart';
|
||||
import '../my_pass/blocs/myPassesDetails/my_passes_details_bloc.dart';
|
||||
import '../my_pass/blocs/myPassesOffers/my_passes_offers_bloc.dart';
|
||||
import '../my_pass/repository/my_passes_attractions_repository.dart';
|
||||
import '../my_pass/repository/my_passes_details_repository.dart';
|
||||
import '../my_pass/repository/my_passes_offers_repository.dart';
|
||||
import '../my_pass/views/booking_page_view.dart';
|
||||
import '../my_pass/views/booking_successful_page_view.dart';
|
||||
import '../my_pass/views/qr_pass_page_view.dart';
|
||||
import '../my_pass/views/pass_details_page_view.dart';
|
||||
import '../offer_pass_detail/offer_pass_detail_view.dart';
|
||||
import '../postcard/blocs/postcard_creation_bloc.dart';
|
||||
import '../postcard/views/postcard_creation_page_view.dart';
|
||||
import '../profile/view/privacy/privacy_view.dart';
|
||||
import '../search_offers/bloc/offers_bloc.dart';
|
||||
import '../search_offers/bloc/search_offers_listing_bloc.dart';
|
||||
import '../search_offers/repository/offers_repository.dart';
|
||||
import '../search_offers/view/search_offers_with_listing.dart';
|
||||
import '../your_itinerary/view/your_itinerary_view.dart';
|
||||
|
||||
@@ -51,11 +64,38 @@ Widget buildOffstageNavigator(
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => AttractionsPage(source: args),
|
||||
);
|
||||
case RouteConstants.passAttractionsPage:
|
||||
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
|
||||
final int cityId = args['cityId'] as int;
|
||||
final String source = args['source'] as String;
|
||||
|
||||
case RouteConstants.attractionDetails:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AttractionDetailsView();
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -78,19 +118,40 @@ Widget buildOffstageNavigator(
|
||||
);
|
||||
|
||||
case RouteConstants.offerPassDetail:
|
||||
return MaterialPageRoute(builder: (_){
|
||||
return OfferPassDetailView();
|
||||
});
|
||||
final offerId = settings.arguments as int;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) => OffersDetailsView(
|
||||
offerId: offerId,
|
||||
),
|
||||
);
|
||||
|
||||
case RouteConstants.searchOffer:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => OffersBloc(),
|
||||
child: SearchOffersWithListing(),
|
||||
create: (_) => OffersBloc(OffersRepository()),
|
||||
child: OffersScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
case RouteConstants.searchPassOffer:
|
||||
final int cityId = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => MyPassesOffersBloc(MyPassesOffersRepository()),
|
||||
child: PassOffersScreen(cityId: cityId),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
case RouteConstants.privacyPolicy:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return const PrivacyPolicyPage();
|
||||
},
|
||||
);
|
||||
|
||||
// 🔹 Upload Photo Page (start of postcard creation flow)
|
||||
case RouteConstants.uploadPhotoPage:
|
||||
@@ -116,12 +177,14 @@ Widget buildOffstageNavigator(
|
||||
);
|
||||
|
||||
case RouteConstants.qrPage:
|
||||
final bookingId = settings.arguments as int;
|
||||
return MaterialPageRoute(
|
||||
builder: (context) {
|
||||
final previousBloc = BlocProvider.of<MyPassBloc>(context);
|
||||
return BlocProvider.value(
|
||||
value: previousBloc,
|
||||
child: const QrPassView(),
|
||||
return BlocProvider(
|
||||
create: (context) => MyPassesDetailsBloc(
|
||||
repository: MyPassesDetailsRepository(),
|
||||
),
|
||||
child: PassDetailsView(bookingId: bookingId),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -153,16 +216,19 @@ Widget buildOffstageNavigator(
|
||||
|
||||
case RouteConstants.magicItineraryFilledScreen:
|
||||
return MaterialPageRoute(builder: (_){
|
||||
return MagicItineraryFilledView();
|
||||
return MagicItineraryView();
|
||||
});
|
||||
|
||||
case RouteConstants.checkout:
|
||||
final bookingId = settings.arguments as int; // or String
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return CheckoutView();
|
||||
},
|
||||
builder: (_) => CheckoutView(
|
||||
bookingId: bookingId,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
case RouteConstants.buyPass:
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
@@ -171,9 +237,13 @@ Widget buildOffstageNavigator(
|
||||
);
|
||||
|
||||
case RouteConstants.createAcct:
|
||||
final email = settings.arguments as String;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return CreateAccountView();
|
||||
return CreateAccountView(
|
||||
email: email, // ✅ required param
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class RouteConstants {
|
||||
static const String home = '/home';
|
||||
static const String registeredUserHome = '/registeredUserHome';
|
||||
static const String attractionsPage = "/attractions";
|
||||
static const String passAttractionsPage = "/passAttractionsPage";
|
||||
static const String postCardPage = "/postcards";
|
||||
static const String uploadPhotoPage = "/uploadPhoto";
|
||||
static const String addFilterPage = "/addFilter";
|
||||
@@ -27,7 +28,8 @@ class RouteConstants {
|
||||
static const String magicItineraryEmptyScreen = '/magicItineraryEmptyScreen';
|
||||
static const String itineraryCreationStart = '/itineraryCreationStart';
|
||||
static const String itineraryCreation = '/itineraryCreation';
|
||||
static const String magicItineraryFilledScreen = "/magicItineraryFilledScreen";
|
||||
static const String magicItineraryFilledScreen =
|
||||
"/magicItineraryFilledScreen";
|
||||
|
||||
/**************************** ESIM Page *****************************************/
|
||||
|
||||
@@ -37,12 +39,14 @@ class RouteConstants {
|
||||
/**************************** Attraction Page *****************************************/
|
||||
|
||||
static const String attractionDetails ='/attractionDetails';
|
||||
static const String passAttractionDetails ='/passAttractionDetails';
|
||||
|
||||
/**************************** By Pass Page Page *****************************************/
|
||||
|
||||
static const String buyPass ='/buyPass';
|
||||
static const String checkout ='/checkout';
|
||||
static const String buyPass = '/buyPass';
|
||||
static const String checkout = '/checkout';
|
||||
static const String searchOffer = '/searchOffer';
|
||||
static const String searchPassOffer = '/searchPassOffer';
|
||||
static const String createAcct = '/createAcct';
|
||||
static const String addDetails = '/addDetails';
|
||||
static const String offerPassDetail = "/offerPassDetail";
|
||||
@@ -56,4 +60,5 @@ class RouteConstants {
|
||||
static const String qrPage = '/qrPage';
|
||||
static const String makeBooking = '/makeBooking';
|
||||
static const String bookingSuccessful = '/bookingSuccessful';
|
||||
static const String editPostCard = '/editPostCard';
|
||||
}
|
||||
|
||||
75
lib/create_account/bloc/create_account_bloc.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../models/create_account_model.dart';
|
||||
import '../repository/create_account_repository.dart';
|
||||
import 'create_account_event.dart';
|
||||
import 'create_account_state.dart';
|
||||
|
||||
class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
|
||||
final CreateAccountRepository repository;
|
||||
|
||||
CreateAccountBloc({required this.repository})
|
||||
: super(const CreateAccountInitial()) {
|
||||
on<CreateAccountSubmitted>(_onCreateAccountSubmitted);
|
||||
on<CreateAccountReset>(_onCreateAccountReset);
|
||||
}
|
||||
|
||||
Future<void> _onCreateAccountSubmitted(
|
||||
CreateAccountSubmitted event,
|
||||
Emitter<CreateAccountState> emit,
|
||||
) async {
|
||||
emit(const CreateAccountLoading());
|
||||
|
||||
try {
|
||||
final response = await repository.registerUser(
|
||||
firstName: event.firstName,
|
||||
lastName: event.lastName,
|
||||
emailAddress: event.emailAddress,
|
||||
mobileNumber: event.mobileNumber,
|
||||
address1: event.address1,
|
||||
address2: event.address2,
|
||||
city: event.city,
|
||||
state: event.state,
|
||||
country: event.country,
|
||||
postalCode: event.postalCode,
|
||||
);
|
||||
await LocalPreference.setLogin(true);
|
||||
// ✅ FIX: Parse directly from response, just like verify OTP
|
||||
final userModel = UserRegisteredModel.fromJson(response);
|
||||
|
||||
await LocalPreference.setTokens(
|
||||
accessToken: userModel.accessToken,
|
||||
refreshToken: userModel.refreshToken,
|
||||
refreshTokenMaxAge: userModel.refreshTokenMaxAge,
|
||||
);
|
||||
|
||||
await LocalPreference.setUserDetails(
|
||||
userId: userModel.user.id,
|
||||
firstName: userModel.user.firstName,
|
||||
lastName: userModel.user.lastName,
|
||||
fullName: userModel.user.fullName,
|
||||
emailAddress: userModel.user.emailAddress,
|
||||
role: userModel.user.role,
|
||||
roleId: userModel.user.roleId,
|
||||
);
|
||||
|
||||
await LocalPreference.setProfileImage(userModel.user.profileImage);
|
||||
|
||||
emit(CreateAccountSuccess(
|
||||
message: 'Account created successfully',
|
||||
userData: response,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(CreateAccountFailure(
|
||||
errorMessage: e.toString().replaceAll('Exception: ', ''),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onCreateAccountReset(
|
||||
CreateAccountReset event,
|
||||
Emitter<CreateAccountState> emit,
|
||||
) {
|
||||
emit(const CreateAccountInitial());
|
||||
}
|
||||
}
|
||||
52
lib/create_account/bloc/create_account_event.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class CreateAccountEvent extends Equatable {
|
||||
const CreateAccountEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class CreateAccountSubmitted extends CreateAccountEvent {
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String emailAddress;
|
||||
final String mobileNumber;
|
||||
final String address1;
|
||||
final String address2;
|
||||
final String city;
|
||||
final String state;
|
||||
final String country;
|
||||
final String postalCode;
|
||||
|
||||
const CreateAccountSubmitted({
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.emailAddress,
|
||||
required this.mobileNumber,
|
||||
required this.address1,
|
||||
required this.address2,
|
||||
required this.city,
|
||||
required this.state,
|
||||
required this.country,
|
||||
required this.postalCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
firstName,
|
||||
lastName,
|
||||
emailAddress,
|
||||
mobileNumber,
|
||||
address1,
|
||||
address2,
|
||||
city,
|
||||
state,
|
||||
country,
|
||||
postalCode,
|
||||
];
|
||||
}
|
||||
|
||||
class CreateAccountReset extends CreateAccountEvent {
|
||||
const CreateAccountReset();
|
||||
}
|
||||
38
lib/create_account/bloc/create_account_state.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class CreateAccountState extends Equatable {
|
||||
const CreateAccountState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class CreateAccountInitial extends CreateAccountState {
|
||||
const CreateAccountInitial();
|
||||
}
|
||||
|
||||
class CreateAccountLoading extends CreateAccountState {
|
||||
const CreateAccountLoading();
|
||||
}
|
||||
|
||||
class CreateAccountSuccess extends CreateAccountState {
|
||||
final String message;
|
||||
final Map<String, dynamic> userData;
|
||||
|
||||
const CreateAccountSuccess({
|
||||
required this.message,
|
||||
required this.userData,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, userData];
|
||||
}
|
||||
|
||||
class CreateAccountFailure extends CreateAccountState {
|
||||
final String errorMessage;
|
||||
|
||||
const CreateAccountFailure({required this.errorMessage});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [errorMessage];
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_textfield.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class CreateAccountView extends StatelessWidget {
|
||||
CreateAccountView({super.key});
|
||||
|
||||
final TextEditingController firstNameController = TextEditingController();
|
||||
final TextEditingController lastNameController = TextEditingController();
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController addressController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
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: "Create your account", size: 12.sp),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 26.h,),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: "Personal Information",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "First Name",
|
||||
hint: "Enter your first name",
|
||||
controller: firstNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Last Name",
|
||||
hint: "Enter your last name",
|
||||
controller: lastNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Email",
|
||||
hint: "Enter your email address",
|
||||
controller: emailController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Phone Number",
|
||||
hint: "Enter your phone number",
|
||||
controller: phoneController,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 2.h),
|
||||
|
||||
// Location Details
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: "Location Details",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
|
||||
child: CustomTextField(
|
||||
label: "Address 1",
|
||||
hint: "Enter address manually or tap to search",
|
||||
controller: addressController,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
CustomFilledButton(
|
||||
width: double.infinity,
|
||||
onTap: (){}, label: "Create Account")
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/create_account/models/create_account_model.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
class UserRegisteredModel {
|
||||
final bool verified;
|
||||
final bool userExists;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int refreshTokenMaxAge;
|
||||
final User user;
|
||||
|
||||
UserRegisteredModel({
|
||||
required this.verified,
|
||||
required this.userExists,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.refreshTokenMaxAge,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory UserRegisteredModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserRegisteredModel(
|
||||
verified: json['verified'] ?? false,
|
||||
userExists: json['userExists'] ?? false,
|
||||
accessToken: json['accessToken'] ?? '',
|
||||
refreshToken: json['refreshToken'] ?? '',
|
||||
refreshTokenMaxAge: json['refreshTokenMaxAge'] ?? 0,
|
||||
user: User.fromJson(json['user'] ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'verified': verified,
|
||||
'userExists': userExists,
|
||||
'accessToken': accessToken,
|
||||
'refreshToken': refreshToken,
|
||||
'refreshTokenMaxAge': refreshTokenMaxAge,
|
||||
'user': user.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// ------------------------------------------------------------
|
||||
/// User Model (Nested)
|
||||
/// ------------------------------------------------------------
|
||||
class User {
|
||||
final int id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String fullName;
|
||||
final String emailAddress;
|
||||
final String profileImage; // ✅ newly added
|
||||
final String role;
|
||||
final int roleId;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.fullName,
|
||||
required this.emailAddress,
|
||||
required this.profileImage,
|
||||
required this.role,
|
||||
required this.roleId,
|
||||
});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'] ?? 0,
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'] ?? '',
|
||||
fullName: json['fullName'] ?? '',
|
||||
emailAddress: json['emailAddress'] ?? '',
|
||||
profileImage: json['profileImage'] ?? '',
|
||||
role: json['role'] ?? '',
|
||||
roleId: json['roleId'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'fullName': fullName,
|
||||
'emailAddress': emailAddress,
|
||||
'profileImage': profileImage,
|
||||
'role': role,
|
||||
'roleId': roleId,
|
||||
};
|
||||
}
|
||||
}
|
||||
41
lib/create_account/repository/create_account_repository.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import 'package:citycards_customer/networkApiServices/network_api_services.dart';
|
||||
|
||||
class CreateAccountRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
Future<Map<String, dynamic>> registerUser({
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String emailAddress,
|
||||
required String mobileNumber,
|
||||
required String address1,
|
||||
required String address2,
|
||||
required String city,
|
||||
required String state,
|
||||
required String country,
|
||||
required String postalCode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiServices.postApi(
|
||||
url: ApiUrls.createAccount,
|
||||
data: {
|
||||
"firstName": firstName,
|
||||
"lastName": lastName,
|
||||
"emailAddress": emailAddress,
|
||||
"mobileNumber": mobileNumber,
|
||||
"address1": address1,
|
||||
"address2": address2,
|
||||
"city": city,
|
||||
"state": state,
|
||||
"country": country,
|
||||
"postalCode": postalCode,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to create account: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
396
lib/create_account/view/create_account_view.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_filled_button.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_textfield.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../core/route_constants.dart';
|
||||
import 'package:citycards_customer/my_pass/blocs/myPasses/my_passes_event.dart';
|
||||
import '../../itinerary_creation/bloc/get_itinerary_bloc.dart';
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../../my_pass/blocs/myPasses/my_passes_bloc.dart';
|
||||
import '../../postcard/blocs/myPostCards/my_postcard_bloc.dart';
|
||||
import '../../postcard/blocs/myPostCards/my_postcard_event.dart';
|
||||
import '../../profile/bloc/profile/profile_bloc.dart';
|
||||
import '../../profile/bloc/profile/profile_event.dart';
|
||||
import '../bloc/create_account_bloc.dart';
|
||||
import '../bloc/create_account_event.dart';
|
||||
import '../bloc/create_account_state.dart';
|
||||
import '../repository/create_account_repository.dart';
|
||||
|
||||
class CreateAccountView extends StatefulWidget {
|
||||
final String email;
|
||||
const CreateAccountView({super.key, required this.email});
|
||||
|
||||
@override
|
||||
State<CreateAccountView> createState() => _CreateAccountViewState();
|
||||
}
|
||||
|
||||
class _CreateAccountViewState extends State<CreateAccountView> {
|
||||
final TextEditingController firstNameController = TextEditingController();
|
||||
final TextEditingController lastNameController = TextEditingController();
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController addressController = TextEditingController();
|
||||
final TextEditingController cityController = TextEditingController();
|
||||
final TextEditingController postalController = TextEditingController();
|
||||
|
||||
String? selectedState;
|
||||
String? selectedCountry;
|
||||
|
||||
void _submitForm(BuildContext context) {
|
||||
if (firstNameController.text.trim().isEmpty ||
|
||||
lastNameController.text.trim().isEmpty ||
|
||||
emailController.text.trim().isEmpty ||
|
||||
phoneController.text.trim().isEmpty ||
|
||||
addressController.text.trim().isEmpty ||
|
||||
cityController.text.trim().isEmpty ||
|
||||
selectedState == null ||
|
||||
selectedCountry == null ||
|
||||
postalController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please fill all fields')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<CreateAccountBloc>().add(
|
||||
CreateAccountSubmitted(
|
||||
firstName: firstNameController.text.trim(),
|
||||
lastName: lastNameController.text.trim(),
|
||||
emailAddress: emailController.text.trim(),
|
||||
mobileNumber: phoneController.text.trim(),
|
||||
address1: addressController.text.trim(),
|
||||
address2: '',
|
||||
city: cityController.text.trim(),
|
||||
state: selectedState!,
|
||||
country: selectedCountry!,
|
||||
postalCode: postalController.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
firstNameController.dispose();
|
||||
lastNameController.dispose();
|
||||
emailController.dispose();
|
||||
phoneController.dispose();
|
||||
addressController.dispose();
|
||||
cityController.dispose();
|
||||
postalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
emailController.text = widget.email;
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
CreateAccountBloc(repository: CreateAccountRepository()),
|
||||
child: BlocListener<CreateAccountBloc, CreateAccountState>(
|
||||
listener: (ctx, state) async {
|
||||
if (state is CreateAccountSuccess) {
|
||||
await LocalPreference.setLogin(true);
|
||||
final userId = await LocalPreference.getUserId();
|
||||
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
|
||||
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
|
||||
context.read<MyPostCardBloc>().add(CheckLoginStatus());
|
||||
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
|
||||
// context.read<MyPostCardBloc>().add(FetchDraftPostCards());
|
||||
context.read<MyPostCardBloc>().add(RefreshDraftPostCards());
|
||||
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
|
||||
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
} else if (state is CreateAccountFailure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showCart: false,
|
||||
showDivider: true,
|
||||
),
|
||||
),
|
||||
|
||||
/// 🔹 Scrollable content starts here
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Icon(Icons.arrow_back),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
CustomText(
|
||||
text: "Create your account",
|
||||
size: 12.sp,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 26.h),
|
||||
|
||||
CustomText(
|
||||
text: "Personal Information",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "First Name",
|
||||
hint: "Enter your first name",
|
||||
controller: firstNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Last Name",
|
||||
hint: "Enter your last name",
|
||||
controller: lastNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Email",
|
||||
hint: "Enter your email address",
|
||||
controller: emailController,
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Phone Number",
|
||||
hint: "Enter your phone number",
|
||||
controller: phoneController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 10,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
CustomText(
|
||||
text: "Location Details",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Address",
|
||||
hint: "Enter address manually or tap to search",
|
||||
controller: addressController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "City",
|
||||
hint: "Enter your city",
|
||||
controller: cityController,
|
||||
),
|
||||
),
|
||||
|
||||
// State Dropdown
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: "State", size: 14.sp),
|
||||
SizedBox(height: 6.h),
|
||||
Container(
|
||||
height: 42.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: const Color(0xBBC83B61).withOpacity(0.4),
|
||||
width: 0.4.w,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedState,
|
||||
isExpanded: true,
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF8E8E8E),
|
||||
),
|
||||
hint: Text(
|
||||
"Select state",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF8E8E8E),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF2D3134),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedState = value;
|
||||
});
|
||||
},
|
||||
items: [
|
||||
"New South Wales",
|
||||
"Victoria",
|
||||
"Queensland",
|
||||
"South Australia",
|
||||
"Western Australia",
|
||||
"Tasmania",
|
||||
"Northern Territory",
|
||||
"Australian Capital Territory"
|
||||
].map((value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Country Dropdown
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(text: "Country", size: 14.sp),
|
||||
SizedBox(height: 6.h),
|
||||
Container(
|
||||
height: 42.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: const Color(0xBBC83B61).withOpacity(0.4),
|
||||
width: 0.4.w,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedCountry,
|
||||
isExpanded: true,
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Color(0xFF8E8E8E),
|
||||
),
|
||||
hint: Text(
|
||||
"Select country",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: const Color(0xFF8E8E8E),
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF2D3134),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedCountry = value;
|
||||
});
|
||||
},
|
||||
items: ["Australia"].map((value) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: value,
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 14.sp),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Postal Code",
|
||||
hint: "Enter postal / zip code",
|
||||
controller: postalController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
BlocBuilder<CreateAccountBloc, CreateAccountState>(
|
||||
builder: (context, state) {
|
||||
if (state is CreateAccountLoading) {
|
||||
return CustomFilledButton(
|
||||
width: double.infinity,
|
||||
onTap: () {},
|
||||
label: "Creating...",
|
||||
);
|
||||
}
|
||||
return CustomFilledButton(
|
||||
width: double.infinity,
|
||||
onTap: () => _submitForm(context),
|
||||
label: "Create Account",
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/back_widget.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_textfield.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class EditProfilePage extends StatelessWidget {
|
||||
const EditProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TextEditingController firstNameController = TextEditingController();
|
||||
final TextEditingController lastNameController = TextEditingController();
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController addressController = TextEditingController();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Header
|
||||
CommonAppBar(isWhiteLogo: false, isProfilePage: true,showDivider: true,),
|
||||
|
||||
// Back + title
|
||||
backWidget(context,"Edit Profile", Colors.black),
|
||||
SizedBox(height: 33.h),
|
||||
|
||||
// Profile Image
|
||||
CircleAvatar(
|
||||
radius: 38.r,
|
||||
backgroundImage: AssetImage("assets/images/profile_img.png"),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
Text(
|
||||
"Change Profile Picture",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Color(0xFFF95F62),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40.h),
|
||||
|
||||
// Personal Information
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: "Personal Information",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "First Name",
|
||||
hint: "Enter your first name",
|
||||
controller: firstNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Last Name",
|
||||
hint: "Enter your last name",
|
||||
controller: lastNameController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Email",
|
||||
hint: "Enter your email address",
|
||||
controller: emailController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: CustomTextField(
|
||||
label: "Phone Number",
|
||||
hint: "Enter your phone number",
|
||||
controller: phoneController,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 2.h),
|
||||
|
||||
// Location Details
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CustomText(
|
||||
text: "Location Details",
|
||||
size: 18.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
|
||||
child: CustomTextField(
|
||||
label: "Address 1",
|
||||
hint: "Enter address manually or tap to search",
|
||||
controller: addressController,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 26.h),
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFF95F62),
|
||||
side: const BorderSide(color: Colors.transparent),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(38.r),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
),
|
||||
onPressed: () {},
|
||||
child: Text(
|
||||
"Cancel",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFF95F62),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(38.r),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 6.h),
|
||||
),
|
||||
onPressed: () {},
|
||||
child: Text(
|
||||
"Save",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/back_widget.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_expansion_tile.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
class FaqPage extends StatelessWidget {
|
||||
const FaqPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommonAppBar(isWhiteLogo: false, isProfilePage: true, showDivider: true,),
|
||||
backWidget(context,"FAQ", Colors.black),
|
||||
SizedBox(height: 34.h),
|
||||
|
||||
FAQSection(title: "🧭 General FAQs", faqs: generalFAQs),
|
||||
SizedBox(height: 20.h),
|
||||
FAQSection(title: "✈️ Booking & Planning", faqs: bookingFaq),
|
||||
SizedBox(height: 20.h),
|
||||
FAQSection(title: "🌍 Discover & Explore", faqs: discoverFAQs),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Model for FAQ
|
||||
class FAQItem {
|
||||
final String question;
|
||||
final String answer;
|
||||
|
||||
FAQItem({required this.question, required this.answer});
|
||||
}
|
||||
|
||||
// Sample FAQ data
|
||||
final List<FAQItem> generalFAQs = [
|
||||
FAQItem(
|
||||
question: "What is CityCards?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
FAQItem(
|
||||
question: "Is the app free to use?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
FAQItem(
|
||||
question: "Do I need an account to use the app?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
];
|
||||
|
||||
final List<FAQItem> discoverFAQs = [
|
||||
FAQItem(
|
||||
question: "How does the app recommend destinations?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
FAQItem(
|
||||
question: "Can I create a custom itinerary?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
FAQItem(
|
||||
question: "Does the app work offline?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
];
|
||||
|
||||
final List<FAQItem> bookingFaq = [
|
||||
FAQItem(
|
||||
question: "Can I modify or cancel my bookings?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
FAQItem(
|
||||
question: "Can I plan multi-city trips?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
FAQItem(
|
||||
question: "Can I book hotels through the app?",
|
||||
answer:
|
||||
"You can browse without an account, but signing up allows you to save trips, sync data, and get personalized recommendations.",
|
||||
),
|
||||
];
|
||||
|
||||
// Widget for FAQ section
|
||||
Widget FAQSection({required String title, required List<FAQItem> faqs}) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 8.w),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section heading
|
||||
CustomText(
|
||||
text: title,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFF212121),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
// Dynamic list of questions
|
||||
Column(
|
||||
children: faqs.map((faq) {
|
||||
int index = faqs.indexOf(faq);
|
||||
return Column(
|
||||
children: [
|
||||
CustomExpansionTile(
|
||||
minTileHeight: 42.h,
|
||||
borderRadius: BorderRadius.circular(5.r),
|
||||
backgroundColor: Color(0xFFFEE7E7),
|
||||
collapsedBackgroundColor: Color(0xFFFEE7E7),
|
||||
tilePadding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 0,
|
||||
),
|
||||
childrenPadding: EdgeInsets.only(left: 12.w,right: 12.w, bottom: 12.h),
|
||||
title: Text(faq.question, style: TextStyle(fontSize: 14.sp)),
|
||||
children: [
|
||||
Text(
|
||||
faq.answer,
|
||||
style: TextStyle(color: Color(0xFF5C5C5C), fontSize: 14.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (index != faqs.length - 1) SizedBox(height: 8.h), // spacing
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repository/first_time_user_home_repository.dart';
|
||||
import '../../model/city_list_model.dart';
|
||||
import 'first_time_user_home_event.dart';
|
||||
import 'first_time_user_home_state.dart';
|
||||
|
||||
class FirstTimeUserHomeBloc
|
||||
extends Bloc<FirstTimeUserHomeEvent, FirstTimeUserHomeState> {
|
||||
final FirstTimeUserHomeRepository repository;
|
||||
|
||||
FirstTimeUserHomeBloc(this.repository)
|
||||
: super(FirstTimeUserHomeInitial()) {
|
||||
on<FetchFirstTimeUserHomeEvent>(_onFetchFirstTimeUserHome);
|
||||
}
|
||||
|
||||
Future<void> _onFetchFirstTimeUserHome(
|
||||
FetchFirstTimeUserHomeEvent event,
|
||||
Emitter<FirstTimeUserHomeState> emit,
|
||||
) async {
|
||||
emit(FirstTimeUserHomeLoading());
|
||||
|
||||
try {
|
||||
final CityList homeData =
|
||||
await repository.fetchFirstTimeUserHome();
|
||||
|
||||
emit(
|
||||
FirstTimeUserHomeLoaded(
|
||||
cities: homeData.cities ?? [],
|
||||
upcomingCities: homeData.upcomingCities ?? [],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(FirstTimeUserHomeError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
abstract class FirstTimeUserHomeEvent {}
|
||||
|
||||
class FetchFirstTimeUserHomeEvent extends FirstTimeUserHomeEvent {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../../model/city_list_model.dart';
|
||||
|
||||
/// Base State
|
||||
abstract class FirstTimeUserHomeState {}
|
||||
|
||||
/// Initial State
|
||||
class FirstTimeUserHomeInitial extends FirstTimeUserHomeState {}
|
||||
|
||||
/// Loading State
|
||||
class FirstTimeUserHomeLoading extends FirstTimeUserHomeState {}
|
||||
|
||||
/// Success State
|
||||
class FirstTimeUserHomeLoaded extends FirstTimeUserHomeState {
|
||||
final List<Cities> cities;
|
||||
final List<UpcomingCities> upcomingCities;
|
||||
|
||||
FirstTimeUserHomeLoaded({
|
||||
required this.cities,
|
||||
required this.upcomingCities,
|
||||
});
|
||||
}
|
||||
|
||||
/// Error State
|
||||
class FirstTimeUserHomeError extends FirstTimeUserHomeState {
|
||||
final String message;
|
||||
|
||||
FirstTimeUserHomeError(this.message);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// --- Events ---
|
||||
abstract class AppStartEvent {}
|
||||
|
||||
class CheckFirstTimeUser extends AppStartEvent {}
|
||||
class MarkUserAsRegistered extends AppStartEvent {}
|
||||
|
||||
/// --- States ---
|
||||
abstract class AppStartState {}
|
||||
|
||||
class AppStartLoading extends AppStartState {}
|
||||
class AppStartFirstTime extends AppStartState {}
|
||||
class AppStartRegistered extends AppStartState {}
|
||||
|
||||
/// --- Bloc ---
|
||||
class AppStartBloc extends Bloc<AppStartEvent, AppStartState> {
|
||||
AppStartBloc() : super(AppStartLoading()) {
|
||||
on<CheckFirstTimeUser>(_onCheckFirstTimeUser);
|
||||
on<MarkUserAsRegistered>(_onMarkUserAsRegistered);
|
||||
}
|
||||
|
||||
Future<void> _onCheckFirstTimeUser(
|
||||
CheckFirstTimeUser event, Emitter<AppStartState> emit) async {
|
||||
emit(AppStartLoading());
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isFirstTime = prefs.getBool('isFirstTimeUser') ?? true;
|
||||
if (isFirstTime) {
|
||||
emit(AppStartFirstTime());
|
||||
} else {
|
||||
emit(AppStartRegistered());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onMarkUserAsRegistered(
|
||||
MarkUserAsRegistered event, Emitter<AppStartState> emit) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('isFirstTimeUser', false);
|
||||
emit(AppStartRegistered());
|
||||
}
|
||||
}
|
||||
|
||||