Compare commits
20 Commits
dinesh
...
53264619a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53264619a8 | ||
|
|
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/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: 749 KiB |
BIN
assets/images/postcard_stamp_logo.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
202
lib/StripePayment/bloc/stripe_payment_bloc.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
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;
|
||||
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
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,
|
||||
) {
|
||||
emit(const StripePaymentCancelled(
|
||||
message: 'Payment cancelled by user',
|
||||
));
|
||||
}
|
||||
|
||||
/// Handle payment retry
|
||||
Future<void> _onRetryPayment(
|
||||
RetryPaymentEvent event,
|
||||
Emitter<StripePaymentState> emit,
|
||||
) async {
|
||||
// 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,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
456
lib/StripePayment/view/stripe_payment.dart
Normal file
@@ -0,0 +1,456 @@
|
||||
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>(
|
||||
listener: (context, state) {
|
||||
if (state is StripePaymentSuccess) {
|
||||
// ✅ 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) {
|
||||
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) {
|
||||
onPaymentCancelled?.call();
|
||||
Navigator.of(context).pop(false);
|
||||
}
|
||||
},
|
||||
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(
|
||||
InitiatePaymentWithClientSecret(
|
||||
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: ["India", "USA", "UK", "Canada"]
|
||||
.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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,304 @@
|
||||
/* -------------------- 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 int cityXid;
|
||||
final int cardTypeXid;
|
||||
final int partnerXid;
|
||||
final String productCode;
|
||||
|
||||
final bool isBookingRequired;
|
||||
final bool isPartnerAccess;
|
||||
final String bookingEmail;
|
||||
final String bookingPhoneNumber;
|
||||
|
||||
final double latitudeCoordinate;
|
||||
final double longitudeCoordinate;
|
||||
final String address;
|
||||
|
||||
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 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?)?.toDouble() ?? 0.0,
|
||||
longitudeCoordinate:
|
||||
(json['longitudeCoordinate'] as num?)?.toDouble() ?? 0.0,
|
||||
address: json['address'] ?? '',
|
||||
ticketPriceAdult: (json['ticketPriceAdult'] as num?)?.toDouble(),
|
||||
ticketPriceChild: (json['ticketPriceChild'] as num?)?.toDouble(),
|
||||
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 int cardTypeXid;
|
||||
final int adultPrice;
|
||||
final int 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,90 @@ 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,
|
||||
);
|
||||
},
|
||||
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),
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
Text(
|
||||
attraction.address,
|
||||
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 +102,70 @@ class AttractionCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
/// TAGS (CARD TITLES)
|
||||
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,
|
||||
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(),
|
||||
)
|
||||
: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 10.w,
|
||||
vertical: 4.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffC1D2F8),
|
||||
border: Border.all(
|
||||
color: const Color(0xff2563EB),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
"Booking Required",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11.sp,
|
||||
color: const Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -140,4 +174,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);
|
||||
}
|
||||
304
lib/buy_a_pass/models/buy_pass_model.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
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 {
|
||||
final City city;
|
||||
final List<Offer> offers;
|
||||
final List<CardPass> cards;
|
||||
final List<Attraction> attractions;
|
||||
|
||||
BuyPassModel({
|
||||
required this.city,
|
||||
required this.offers,
|
||||
required this.cards,
|
||||
required this.attractions,
|
||||
});
|
||||
|
||||
factory BuyPassModel.fromJson(Map<String, dynamic> json) {
|
||||
return BuyPassModel(
|
||||
city: City.fromJson(json['city']),
|
||||
offers: List<Offer>.from(
|
||||
json['offers'].map((x) => Offer.fromJson(x)),
|
||||
),
|
||||
cards: List<CardPass>.from(
|
||||
json['cards'].map((x) => CardPass.fromJson(x)),
|
||||
),
|
||||
attractions: List<Attraction>.from(
|
||||
json['attractions'].map((x) => Attraction.fromJson(x)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"city": city.toJson(),
|
||||
"offers": offers.map((x) => x.toJson()).toList(),
|
||||
"cards": cards.map((x) => x.toJson()).toList(),
|
||||
"attractions": attractions.map((x) => x.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CITY ----------
|
||||
class City {
|
||||
final int id;
|
||||
final String name;
|
||||
final String slug;
|
||||
final String tagLine;
|
||||
final String description;
|
||||
final String bestTimeToVisit;
|
||||
final String priceRange;
|
||||
final num individualTicketAmount; // Changed from int to num
|
||||
final num cityCardTicketAmount; // Changed from int to num
|
||||
final HeroBanner heroBanner;
|
||||
|
||||
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) {
|
||||
return City(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
slug: json['slug'],
|
||||
tagLine: json['tagLine'],
|
||||
description: json['description'],
|
||||
bestTimeToVisit: json['bestTimeToVisit'],
|
||||
priceRange: json['priceRange'],
|
||||
individualTicketAmount: json['individualTicketAmount'],
|
||||
cityCardTicketAmount: json['cityCardTicketAmount'],
|
||||
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 {
|
||||
final String title;
|
||||
final String image;
|
||||
|
||||
HeroBanner({
|
||||
required this.title,
|
||||
required this.image,
|
||||
});
|
||||
|
||||
factory HeroBanner.fromJson(Map<String, dynamic> json) {
|
||||
return HeroBanner(
|
||||
title: json['title'],
|
||||
image: json['image'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"title": title,
|
||||
"image": image,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- OFFER ----------
|
||||
class Offer {
|
||||
final int id;
|
||||
final String title;
|
||||
final String offerCode;
|
||||
final String? description; // ✅ optional
|
||||
final String? redemptionLink; // ✅ optional
|
||||
final String websiteBannerImage;
|
||||
final String mobileBannerImage;
|
||||
final String passType;
|
||||
final DateTime startDateTime;
|
||||
final DateTime endDateTime;
|
||||
final String offerStatus;
|
||||
final bool applyToPasses;
|
||||
|
||||
Offer({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.offerCode,
|
||||
this.description,
|
||||
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) {
|
||||
return Offer(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
offerCode: json['offerCode'],
|
||||
description: json['description'], // ✅
|
||||
redemptionLink: json['redemptionLink'], // ✅
|
||||
websiteBannerImage: json['websiteBannerImage'],
|
||||
mobileBannerImage: json['mobileBannerImage'],
|
||||
passType: json['passType'],
|
||||
startDateTime: DateTime.parse(json['startDateTime']),
|
||||
endDateTime: DateTime.parse(json['endDateTime']),
|
||||
offerStatus: json['offerStatus'],
|
||||
applyToPasses: json['applyToPasses'],
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final int validityDuration;
|
||||
final num adultPrice; // Changed from int to num
|
||||
final num childPrice; // Changed from int to num
|
||||
final int minNumber; // ✅ NEW
|
||||
final int maxNumber; // ✅ NEW
|
||||
final CardType cardType;
|
||||
final List<Offer> offers;
|
||||
|
||||
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) {
|
||||
return CardPass(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
validityDuration: json['validityDuration'],
|
||||
adultPrice: json['adultPrice'],
|
||||
childPrice: json['childPrice'],
|
||||
minNumber: json['minNumber'], // ✅
|
||||
maxNumber: json['maxNumber'], // ✅
|
||||
cardType: CardType.fromJson(json['cardType']),
|
||||
offers: List<Offer>.from(
|
||||
json['offers'].map((x) => Offer.fromJson(x)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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((x) => x.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- CARD TYPE ----------
|
||||
class CardType {
|
||||
final int id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
|
||||
CardType({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
});
|
||||
|
||||
factory CardType.fromJson(Map<String, dynamic> json) {
|
||||
return CardType(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
displayName: json['displayName'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"displayName": displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/// ---------- ATTRACTION ----------
|
||||
class Attraction {
|
||||
final int id;
|
||||
final String title;
|
||||
final String slug;
|
||||
final String thumbnail;
|
||||
final num? startingFrom; // Changed from int? to num?
|
||||
|
||||
Attraction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.slug,
|
||||
required this.thumbnail,
|
||||
this.startingFrom,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic> json) {
|
||||
return Attraction(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
slug: json['slug'],
|
||||
thumbnail: json['thumbnail'],
|
||||
startingFrom: json['startingFrom'],
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
// );
|
||||
},
|
||||
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 Card",
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
lib/cart/blocs/myPassCart/my_pass_cart_bloc.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
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<FetchPassCartEvent>(_onFetchPassCart);
|
||||
on<ClearPassCartEvent>(_onClearPassCart);
|
||||
}
|
||||
|
||||
/// Handle fetching pass cart data
|
||||
Future<void> _onFetchPassCart(
|
||||
FetchPassCartEvent event,
|
||||
Emitter<MyPassCartState> emit,
|
||||
) async {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('🔄 [BLOC] Fetching pass cart...');
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/cart/blocs/myPassCart/my_pass_cart_event.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class MyPassCartEvent extends Equatable {
|
||||
const MyPassCartEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
43
lib/cart/blocs/myPassCart/my_pass_cart_state.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:equatable/equatable.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
|
||||
class MyPassCartLoaded extends MyPassCartState {
|
||||
final Map<String, dynamic> cartData;
|
||||
|
||||
const MyPassCartLoaded({required this.cartData});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cartData];
|
||||
}
|
||||
|
||||
/// 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];
|
||||
}
|
||||
35
lib/cart/repository/my_pass_cart_repository.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../localPreference/local_preference.dart';
|
||||
|
||||
class MyPassCartRepository {
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
486
lib/cart/views/my_pass_cart_page_view.dart
Normal file
@@ -0,0 +1,486 @@
|
||||
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 '../../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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Fetch cart data when page loads
|
||||
context.read<MyPassCartBloc>().add(const FetchPassCartEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<MyPassCartBloc, MyPassCartState>(
|
||||
builder: (context, state) {
|
||||
if (state is MyPassCartLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (state is MyPassCartLoaded) {
|
||||
final cartData = state.cartData;
|
||||
|
||||
// Extract data from cart
|
||||
final String cityName = cartData['city_name'] as String? ?? '';
|
||||
final String heroImage = cartData['hero_image'] as String? ?? '';
|
||||
final String cardTypeName = cartData['card_type_name'] as String? ?? '';
|
||||
final String cardDisplayName = cartData['card_display_name'] as String? ?? '';
|
||||
final int themeColor = cartData['theme_color'] as int? ?? 0xFFF95FAF;
|
||||
final int adultCount = cartData['adult_count'] as int? ?? 0;
|
||||
final int childCount = cartData['child_count'] as int? ?? 0;
|
||||
final double adultPrice = (cartData['adult_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final double childPrice = (cartData['child_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final int validityDuration = cartData['validity_duration'] as int? ?? 0;
|
||||
final double totalPrice = (cartData['total_price'] as num?)?.toDouble() ?? 0.0;
|
||||
final String? description = cartData['description'] as String?;
|
||||
|
||||
// Calculate pricing
|
||||
final double subtotal = totalPrice;
|
||||
final double discountAmount = subtotal * (discountPercentage / 100);
|
||||
final double taxRate = 0.05; // 5% tax
|
||||
final double totalBeforeTax = subtotal - discountAmount;
|
||||
final double taxAmount = totalBeforeTax * taxRate;
|
||||
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,
|
||||
),
|
||||
),
|
||||
// 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(),
|
||||
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),
|
||||
|
||||
// FutureBuilder for login check
|
||||
FutureBuilder<bool>(
|
||||
future: LocalPreference.getLogin(),
|
||||
builder: (context, snapshot) {
|
||||
final isLoggedIn = snapshot.data ?? false;
|
||||
|
||||
return CustomFilledButton(
|
||||
onTap: () {
|
||||
if (!isLoggedIn) {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.white,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(12.r),
|
||||
),
|
||||
),
|
||||
builder: (_) => const LoginEmailBottomsheet(),
|
||||
);
|
||||
} else {
|
||||
// Handle checkout logic for logged in user
|
||||
// You can navigate to checkout or payment screen
|
||||
print("✅ User is logged in, proceed to checkout");
|
||||
}
|
||||
},
|
||||
width: double.infinity,
|
||||
label: isLoggedIn ? "Checkout" : "Login to Checkout",
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 25.h),
|
||||
],
|
||||
);
|
||||
} else if (state is MyPassCartEmpty) {
|
||||
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});
|
||||
}
|
||||
250
lib/checkout/bloc/checkOut/checkout_bloc.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
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 {
|
||||
// Show loading state
|
||||
if (state is CheckoutCouponsLoadedState) {
|
||||
final currentState = state as CheckoutCouponsLoadedState;
|
||||
emit(currentState.copyWith(
|
||||
isConfirmingPayment: true,
|
||||
confirmationError: null,
|
||||
isPaymentConfirmed: false,
|
||||
));
|
||||
} 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(),
|
||||
));
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
119
lib/checkout/bloc/checkOut/checkout_state.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
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
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
}) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
'recipientName': recipientFirstName ?? '',
|
||||
// 'recipientLastName': recipientLastName ?? '',
|
||||
'recipientEmail': recipientEmail ?? '',
|
||||
'recipientPhone': recipientPhone ?? '',
|
||||
// 'city': city ?? '',
|
||||
// 'country': 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,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,12 @@ class CustomTextField extends StatelessWidget {
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final int? maxLines;
|
||||
final bool enabled;
|
||||
final String? Function(String?)? validator; // ✅ NEW: Validator function
|
||||
final TextInputType? keyboardType; // ✅ NEW: Keyboard type
|
||||
final bool obscureText; // ✅ NEW: For password fields
|
||||
final Widget? suffixIcon; // ✅ NEW: For icons like visibility toggle
|
||||
final void Function(String)? onChanged; // ✅ NEW: OnChanged callback
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
@@ -14,6 +20,12 @@ 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,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -23,33 +35,75 @@ 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( // ✅ Changed from TextField to TextFormField
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
maxLines: obscureText ? 1 : maxLines, // ✅ Password fields always single line
|
||||
enabled: enabled,
|
||||
validator: validator, // ✅ Added validator
|
||||
keyboardType: keyboardType, // ✅ Added keyboard type
|
||||
obscureText: obscureText, // ✅ Added obscure text
|
||||
onChanged: onChanged, // ✅ Added onChanged
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(fontSize: 12.sp, color: Color(0xFF8E8E8E)),
|
||||
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, // ✅ Better padding for multiline
|
||||
),
|
||||
suffixIcon: suffixIcon, // ✅ Added suffix icon
|
||||
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,
|
||||
),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.shade400,
|
||||
width: .4.w,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder( // ✅ NEW: Error state border
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1.w,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder( // ✅ NEW: Focused error state
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.red,
|
||||
width: 1.5.w,
|
||||
),
|
||||
),
|
||||
errorStyle: TextStyle( // ✅ NEW: Error text style
|
||||
fontSize: 11.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -57,4 +111,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,11 @@
|
||||
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 +13,11 @@ 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/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 +27,14 @@ 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 '../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 {
|
||||
@@ -146,9 +149,10 @@ class AppRouter {
|
||||
);
|
||||
|
||||
case RouteConstants.attractionDetails:
|
||||
final attractionId = settings.arguments as Attraction;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AttractionDetailsView();
|
||||
return AttractionDetailsView(attractionId: attractionId.id);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -160,10 +164,10 @@ 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:
|
||||
@@ -177,23 +181,29 @@ class AppRouter {
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return BlocProvider(
|
||||
create: (_) => OffersBloc(),
|
||||
child: SearchOffersWithListing(),
|
||||
create: (_) => OffersBloc(OffersRepository()),
|
||||
child: OffersScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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,15 +224,15 @@ 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:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -5,23 +6,25 @@ 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/views/booking_page_view.dart';
|
||||
import '../my_pass/views/booking_successful_page_view.dart';
|
||||
import '../my_pass/views/qr_pass_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 '../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';
|
||||
|
||||
@@ -53,9 +56,10 @@ Widget buildOffstageNavigator(
|
||||
);
|
||||
|
||||
case RouteConstants.attractionDetails:
|
||||
final attraction = settings.arguments as Attraction;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AttractionDetailsView();
|
||||
return AttractionDetailsView(attractionId: attraction.id);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -78,16 +82,20 @@ 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -153,16 +161,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 +182,13 @@ Widget buildOffstageNavigator(
|
||||
);
|
||||
|
||||
case RouteConstants.createAcct:
|
||||
final email = settings.arguments as String;
|
||||
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return CreateAccountView();
|
||||
return CreateAccountView(
|
||||
email: email, // ✅ required param
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class RouteConstants {
|
||||
|
||||
|
||||
static const String intro = '/intro';
|
||||
static const String splash = '/splash';
|
||||
|
||||
@@ -27,33 +25,33 @@ 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 *****************************************/
|
||||
|
||||
static const String esimOffer = '/esim_offer';
|
||||
static const String hotelOffer = '/hotelOffer';
|
||||
|
||||
/**************************** Attraction Page *****************************************/
|
||||
/**************************** Attraction Page *****************************************/
|
||||
|
||||
static const String attractionDetails ='/attractionDetails';
|
||||
static const String attractionDetails = '/attractionDetails';
|
||||
|
||||
/**************************** 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 createAcct = '/createAcct';
|
||||
static const String addDetails = '/addDetails';
|
||||
static const String offerPassDetail = "/offerPassDetail";
|
||||
|
||||
|
||||
/************************** My card page ***************************************/
|
||||
static const String cartPage = '/cartPage';
|
||||
static const String yourItinerary = '/yourItinerary';
|
||||
|
||||
|
||||
static const String qrPage = '/qrPage';
|
||||
static const String makeBooking = '/makeBooking';
|
||||
static const String bookingSuccessful = '/bookingSuccessful';
|
||||
static const String editPostCard = '/editPostCard';
|
||||
}
|
||||
|
||||
66
lib/create_account/bloc/create_account_bloc.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
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,
|
||||
);
|
||||
|
||||
final userModel = UserRegisteredModel.fromJson(response['data'] ?? {});
|
||||
await LocalPreference.setTokens(
|
||||
accessToken: userModel.accessToken,
|
||||
refreshToken: userModel.refreshToken,
|
||||
refreshTokenMaxAge: userModel.refreshTokenMaxAge,
|
||||
);
|
||||
await LocalPreference.setUserDetails(
|
||||
userId: userModel.user.id,
|
||||
firstName: userModel.user.firstName,
|
||||
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: response['message'] ?? 'Account created successfully',
|
||||
userData: response['data'] ?? {},
|
||||
));
|
||||
} catch (e) {
|
||||
emit(CreateAccountFailure(
|
||||
errorMessage: e.toString().replaceAll('Exception: ', ''),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onCreateAccountReset(
|
||||
CreateAccountReset event,
|
||||
Emitter<CreateAccountState> emit,
|
||||
) {
|
||||
emit(const CreateAccountInitial());
|
||||
}
|
||||
}
|
||||
40
lib/create_account/bloc/create_account_event.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
|
||||
const CreateAccountSubmitted({
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.emailAddress,
|
||||
required this.mobileNumber,
|
||||
required this.address1,
|
||||
required this.address2,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
firstName,
|
||||
lastName,
|
||||
emailAddress,
|
||||
mobileNumber,
|
||||
address1,
|
||||
address2,
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
33
lib/create_account/repository/create_account_repository.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiServices.postApi(
|
||||
url: ApiUrls.createAccount,
|
||||
data: {
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'emailAddress': emailAddress,
|
||||
'mobileNumber': mobileNumber,
|
||||
'address1': address1,
|
||||
'address2': address2,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to create account: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
208
lib/create_account/view/create_account_view.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
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 '../../localPreference/local_preference.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 StatelessWidget {
|
||||
final String email;
|
||||
CreateAccountView({super.key, required this.email});
|
||||
|
||||
final TextEditingController firstNameController = TextEditingController();
|
||||
final TextEditingController lastNameController = TextEditingController();
|
||||
final TextEditingController emailController = TextEditingController();
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
final TextEditingController addressController = TextEditingController();
|
||||
|
||||
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) {
|
||||
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: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
emailController.text = email;
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
CreateAccountBloc(repository: CreateAccountRepository()),
|
||||
child: BlocListener<CreateAccountBloc, CreateAccountState>(
|
||||
listener: (ctx, state) async {
|
||||
if (state is CreateAccountSuccess) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.message)));
|
||||
await LocalPreference.setLogin(true);
|
||||
final userId = await LocalPreference.getUserId();
|
||||
context.read<ProfileBloc>().add(FetchProfileEvent(userId: userId!));
|
||||
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
|
||||
Navigator.pop(context);
|
||||
} 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,
|
||||
),
|
||||
),
|
||||
|
||||
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 1",
|
||||
hint: "Enter address manually or tap to search",
|
||||
controller: addressController,
|
||||
),
|
||||
),
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
25
lib/home/bloc/registeredHome/home_bloc.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repository/home_repository.dart';
|
||||
import 'home_event.dart';
|
||||
import 'home_state.dart';
|
||||
|
||||
class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
final HomeRepository homeRepository;
|
||||
|
||||
HomeBloc({required this.homeRepository}) : super(HomeInitial()) {
|
||||
on<FetchHomeData>(_onFetchHomeData);
|
||||
}
|
||||
|
||||
Future<void> _onFetchHomeData(
|
||||
FetchHomeData event,
|
||||
Emitter<HomeState> emit,
|
||||
) async {
|
||||
emit(HomeLoading());
|
||||
try {
|
||||
final homeModel = await homeRepository.fetchHomeData();
|
||||
emit(HomeLoaded(homeModel));
|
||||
} catch (e) {
|
||||
emit(HomeError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
3
lib/home/bloc/registeredHome/home_event.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
abstract class HomeEvent {}
|
||||
|
||||
class FetchHomeData extends HomeEvent {}
|
||||
19
lib/home/bloc/registeredHome/home_state.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import '../../model/home_model.dart';
|
||||
|
||||
abstract class HomeState {}
|
||||
|
||||
class HomeInitial extends HomeState {}
|
||||
|
||||
class HomeLoading extends HomeState {}
|
||||
|
||||
class HomeLoaded extends HomeState {
|
||||
final HomeModel homeModel;
|
||||
|
||||
HomeLoaded(this.homeModel);
|
||||
}
|
||||
|
||||
class HomeError extends HomeState {
|
||||
final String message;
|
||||
|
||||
HomeError(this.message);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../model/city_selection_model.dart';
|
||||
import '../repository/search_city_repository.dart';
|
||||
|
||||
|
||||
abstract class LoadCityEvent {}
|
||||
|
||||
@@ -6,49 +9,57 @@ class LoadAllCity extends LoadCityEvent {}
|
||||
|
||||
class SearchCity extends LoadCityEvent {
|
||||
final String query;
|
||||
|
||||
SearchCity(this.query);
|
||||
}
|
||||
|
||||
// ----- State -----
|
||||
class CityState {
|
||||
final List<Map<String, String>> offers;
|
||||
abstract class CityState {}
|
||||
|
||||
const CityState(this.offers);
|
||||
class CityInitial extends CityState {}
|
||||
|
||||
class CityLoading extends CityState {}
|
||||
|
||||
class CityLoaded extends CityState {
|
||||
final List<CitySelection> cities;
|
||||
CityLoaded(this.cities);
|
||||
}
|
||||
|
||||
// ----- Bloc -----
|
||||
class CityError extends CityState {
|
||||
final String message;
|
||||
CityError(this.message);
|
||||
}
|
||||
|
||||
|
||||
class SearchCityBloc extends Bloc<LoadCityEvent, CityState> {
|
||||
SearchCityBloc() : super(const CityState([])) {
|
||||
final SearchCityRepository repository;
|
||||
|
||||
SearchCityBloc(this.repository) : super(CityInitial()) {
|
||||
on<LoadAllCity>(_onLoadCity);
|
||||
on<SearchCity>(_onSearchCity);
|
||||
}
|
||||
|
||||
final List<Map<String, String>> _allOffers = [
|
||||
{"image": "assets/images/aa1.png", "title": "Sydney"},
|
||||
{"image": "assets/images/aa2.png", "title": "New York"},
|
||||
{"image": "assets/images/aa3.png", "title": "Abu Dhabi"},
|
||||
{"image": "assets/images/aa4.png", "title": "Dubai"},
|
||||
{
|
||||
"image": "assets/images/card_banner.png",
|
||||
"title": "Tokyo",
|
||||
},
|
||||
{"image": "assets/images/city_germany.jpg", "title": "Ontario"},
|
||||
{"image": "assets/images/aa2.png", "title": "Mumbai"},
|
||||
{"image": "assets/images/aa3.png", "title": "Louisiana"},
|
||||
];
|
||||
|
||||
void _onLoadCity(event, emit) {
|
||||
emit(CityState(_allOffers));
|
||||
Future<void> _onLoadCity(
|
||||
LoadAllCity event,
|
||||
Emitter<CityState> emit,
|
||||
) async {
|
||||
emit(CityLoading());
|
||||
try {
|
||||
final response = await repository.fetchAllCities();
|
||||
emit(CityLoaded(response.cities));
|
||||
} catch (e) {
|
||||
emit(CityError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchCity(event, emit) {
|
||||
final filtered = _allOffers
|
||||
.where(
|
||||
(offer) =>
|
||||
offer["title"]!.toLowerCase().contains(event.query.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
emit(CityState(filtered));
|
||||
Future<void> _onSearchCity(
|
||||
SearchCity event,
|
||||
Emitter<CityState> emit,
|
||||
) async {
|
||||
emit(CityLoading());
|
||||
try {
|
||||
final cities = await repository.searchCities(event.query);
|
||||
emit(CityLoaded(cities));
|
||||
} catch (e) {
|
||||
emit(CityError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
lib/home/model/city_selection_model.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
class CitySelectionResponse {
|
||||
final List<CitySelection> cities;
|
||||
|
||||
CitySelectionResponse({required this.cities});
|
||||
|
||||
factory CitySelectionResponse.fromJson(Map<String, dynamic> json) {
|
||||
return CitySelectionResponse(
|
||||
cities: (json['cities'] as List<dynamic>?)
|
||||
?.map((city) => CitySelection.fromJson(city as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'cities': cities.map((city) => city.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CitySelection {
|
||||
final int id;
|
||||
final String cityName;
|
||||
final String bannerImage;
|
||||
|
||||
CitySelection({
|
||||
required this.id,
|
||||
required this.cityName,
|
||||
required this.bannerImage,
|
||||
});
|
||||
|
||||
factory CitySelection.fromJson(Map<String, dynamic> json) {
|
||||
return CitySelection(
|
||||
id: json['id'] as int? ?? 0,
|
||||
cityName: json['cityName'] as String? ?? '',
|
||||
bannerImage: json['bannerImage'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'cityName': cityName,
|
||||
'bannerImage': bannerImage,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to get the image URL with fallback
|
||||
String getImageUrl() {
|
||||
if (bannerImage.isEmpty || !bannerImage.startsWith('http')) {
|
||||
return 'assets/images/card_banner.png';
|
||||
}
|
||||
return bannerImage;
|
||||
}
|
||||
|
||||
// Helper method to check if image is network image
|
||||
bool isNetworkImage() {
|
||||
return bannerImage.isNotEmpty && bannerImage.startsWith('http');
|
||||
}
|
||||
}
|
||||
422
lib/home/model/home_model.dart
Normal file
@@ -0,0 +1,422 @@
|
||||
class HomeModel {
|
||||
final City? city;
|
||||
final List<Attraction>? attraction;
|
||||
|
||||
HomeModel({
|
||||
this.city,
|
||||
this.attraction,
|
||||
});
|
||||
|
||||
factory HomeModel.fromJson(Map<String, dynamic> json) {
|
||||
return HomeModel(
|
||||
city: json['city'] != null ? City.fromJson(json['city']) : null,
|
||||
attraction: json['attraction'] != null
|
||||
? List<Attraction>.from(
|
||||
json['attraction'].map((x) => Attraction.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* CITY */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class City {
|
||||
final int id;
|
||||
final String cityName;
|
||||
final String urlSlug;
|
||||
final String tagLine;
|
||||
final String cityIconPath;
|
||||
final String description;
|
||||
final String metaTitle;
|
||||
final String metaDescription;
|
||||
final String bestTimeToVisit;
|
||||
final String priceRange;
|
||||
final int indivisualTicketAmt;
|
||||
final int cityCardTicketAmt;
|
||||
final String seoTitle;
|
||||
final String seoDescription;
|
||||
final int displayOrder;
|
||||
final bool isActive;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
final List<CityBanner> cityBanners;
|
||||
final List<CardModel> cards;
|
||||
final List<CityFeatureCard> cityFeatureCards;
|
||||
final List<CityHighlight> cityHighlights;
|
||||
|
||||
City({
|
||||
required this.id,
|
||||
required this.cityName,
|
||||
required this.urlSlug,
|
||||
required this.tagLine,
|
||||
required this.cityIconPath,
|
||||
required this.description,
|
||||
required this.metaTitle,
|
||||
required this.metaDescription,
|
||||
required this.bestTimeToVisit,
|
||||
required this.priceRange,
|
||||
required this.indivisualTicketAmt,
|
||||
required this.cityCardTicketAmt,
|
||||
required this.seoTitle,
|
||||
required this.seoDescription,
|
||||
required this.displayOrder,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.cityBanners,
|
||||
required this.cards,
|
||||
required this.cityFeatureCards,
|
||||
required this.cityHighlights,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic> json) {
|
||||
return City(
|
||||
id: json['id'] ?? 0,
|
||||
cityName: json['cityName'] ?? 'N/A',
|
||||
urlSlug: json['urlSlug'] ?? 'N/A',
|
||||
tagLine: json['tagLine'] ?? 'N/A',
|
||||
cityIconPath: json['cityIconPath'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
metaTitle: json['metaTitle'] ?? 'N/A',
|
||||
metaDescription: json['metaDescription'] ?? 'N/A',
|
||||
bestTimeToVisit: json['bestTimeToVisit'] ?? 'N/A',
|
||||
priceRange: json['priceRange'] ?? 'N/A',
|
||||
indivisualTicketAmt: json['indivisualTicketAmt'] ?? 0,
|
||||
cityCardTicketAmt: json['cityCardTicketAmt'] ?? 0,
|
||||
seoTitle: json['seoTitle'] ?? 'N/A',
|
||||
seoDescription: json['seoDescription'] ?? 'N/A',
|
||||
displayOrder: json['displayOrder'] ?? 0,
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] ?? 'N/A',
|
||||
updatedAt: json['updatedAt'] ?? 'N/A',
|
||||
cityBanners: json['cityBanners'] != null
|
||||
? List<CityBanner>.from(
|
||||
json['cityBanners'].map((x) => CityBanner.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
cards: json['cards'] != null
|
||||
? List<CardModel>.from(
|
||||
json['cards'].map((x) => CardModel.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
cityFeatureCards: json['cityFeatureCards'] != null
|
||||
? List<CityFeatureCard>.from(
|
||||
json['cityFeatureCards'].map((x) => CityFeatureCard.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
cityHighlights: json['cityHighlights'] != null
|
||||
? List<CityHighlight>.from(
|
||||
json['cityHighlights'].map((x) => CityHighlight.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* CITY BANNER */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class CityBanner {
|
||||
final int id;
|
||||
final int cityXid;
|
||||
final String title;
|
||||
final String highlightWord;
|
||||
final String description;
|
||||
final String imageFilePath;
|
||||
final String ctaLabel;
|
||||
final String ctaUrl;
|
||||
final bool isActive;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
CityBanner({
|
||||
required this.id,
|
||||
required this.cityXid,
|
||||
required this.title,
|
||||
required this.highlightWord,
|
||||
required this.description,
|
||||
required this.imageFilePath,
|
||||
required this.ctaLabel,
|
||||
required this.ctaUrl,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory CityBanner.fromJson(Map<String, dynamic> json) {
|
||||
return CityBanner(
|
||||
id: json['id'] ?? 0,
|
||||
cityXid: json['cityXid'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
highlightWord: json['highlightWord'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
imageFilePath: json['imageFilePath'] ?? 'N/A',
|
||||
ctaLabel: json['ctaLabel'] ?? 'N/A',
|
||||
ctaUrl: json['ctaUrl'] ?? 'N/A',
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] ?? 'N/A',
|
||||
updatedAt: json['updatedAt'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* CARD */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class CardModel {
|
||||
final int id;
|
||||
final int cityXid;
|
||||
final String title;
|
||||
final String description;
|
||||
final int cardTypeXid;
|
||||
final int minNumber;
|
||||
final int maxNumber;
|
||||
final int validityDuration;
|
||||
final bool isMultiplyEntry;
|
||||
final num adultPrice; // Changed from int to num
|
||||
final num childPrice; // Changed from int to num
|
||||
final String cardStatus;
|
||||
final bool isActive;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
CardModel({
|
||||
required this.id,
|
||||
required this.cityXid,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.cardTypeXid,
|
||||
required this.minNumber,
|
||||
required this.maxNumber,
|
||||
required this.validityDuration,
|
||||
required this.isMultiplyEntry,
|
||||
required this.adultPrice,
|
||||
required this.childPrice,
|
||||
required this.cardStatus,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory CardModel.fromJson(Map<String, dynamic> json) {
|
||||
return CardModel(
|
||||
id: json['id'] ?? 0,
|
||||
cityXid: json['cityXid'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
cardTypeXid: json['cardTypeXid'] ?? 0,
|
||||
minNumber: json['minNumber'] ?? 0,
|
||||
maxNumber: json['maxNumber'] ?? 0,
|
||||
validityDuration: json['validityDuration'] ?? 0,
|
||||
isMultiplyEntry: json['isMultiplyEntry'] ?? false,
|
||||
adultPrice: json['adultPrice'] ?? 0,
|
||||
childPrice: json['childPrice'] ?? 0,
|
||||
cardStatus: json['cardStatus'] ?? 'N/A',
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] ?? 'N/A',
|
||||
updatedAt: json['updatedAt'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* CITY FEATURE CARD */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class CityFeatureCard {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final FeatureCardIcon? icon; // ← CHANGED: Now uses FeatureCardIcon object
|
||||
|
||||
CityFeatureCard({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.icon, // ← CHANGED: Now nullable
|
||||
});
|
||||
|
||||
factory CityFeatureCard.fromJson(Map<String, dynamic> json) {
|
||||
return CityFeatureCard(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
icon: json['icon'] != null
|
||||
? FeatureCardIcon.fromJson(json['icon']) // ← CHANGED: Parse as object
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* FEATURE CARD ICON */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class FeatureCardIcon {
|
||||
final int id;
|
||||
final String iconName;
|
||||
final String iconSvg;
|
||||
|
||||
FeatureCardIcon({
|
||||
required this.id,
|
||||
required this.iconName,
|
||||
required this.iconSvg,
|
||||
});
|
||||
|
||||
factory FeatureCardIcon.fromJson(Map<String, dynamic> json) {
|
||||
return FeatureCardIcon(
|
||||
id: json['id'] ?? 0,
|
||||
iconName: json['iconName'] ?? 'N/A',
|
||||
iconSvg: json['iconSvg'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* CITY HIGHLIGHTS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class CityHighlight {
|
||||
final int id;
|
||||
final int cityXid;
|
||||
final String title;
|
||||
final int iconXid;
|
||||
final bool isActive;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
final CityHighlightIcon? icon;
|
||||
|
||||
CityHighlight({
|
||||
required this.id,
|
||||
required this.cityXid,
|
||||
required this.title,
|
||||
required this.iconXid,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
factory CityHighlight.fromJson(Map<String, dynamic> json) {
|
||||
return CityHighlight(
|
||||
id: json['id'] ?? 0,
|
||||
cityXid: json['cityXid'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
iconXid: json['iconXid'] ?? 0,
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] ?? 'N/A',
|
||||
updatedAt: json['updatedAt'] ?? 'N/A',
|
||||
icon: json['icon'] != null ? CityHighlightIcon.fromJson(json['icon']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CityHighlightIcon {
|
||||
final int id;
|
||||
final String iconName;
|
||||
final String iconSvg;
|
||||
final bool isActive;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
CityHighlightIcon({
|
||||
required this.id,
|
||||
required this.iconName,
|
||||
required this.iconSvg,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory CityHighlightIcon.fromJson(Map<String, dynamic> json) {
|
||||
return CityHighlightIcon(
|
||||
id: json['id'] ?? 0,
|
||||
iconName: json['iconName'] ?? 'N/A',
|
||||
iconSvg: json['iconSvg'] ?? 'N/A',
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] ?? 'N/A',
|
||||
updatedAt: json['updatedAt'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ATTRACTION */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class Attraction {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String urlSlug;
|
||||
final List<AttractionGallery> attractionGalleries;
|
||||
|
||||
Attraction({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.urlSlug,
|
||||
required this.attractionGalleries,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic> json) {
|
||||
return Attraction(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
urlSlug: json['urlSlug'] ?? 'N/A',
|
||||
attractionGalleries: json['attractionGalleries'] != null
|
||||
? List<AttractionGallery>.from(
|
||||
json['attractionGalleries'].map((x) => AttractionGallery.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 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 String createdAt;
|
||||
final String 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'] ?? 'N/A',
|
||||
updatedAt: json['updatedAt'] ?? 'N/A',
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/home/repository/first_time_user_home_repository.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../model/city_list_model.dart';
|
||||
|
||||
class FirstTimeUserHomeRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
/// Fetch full home data (cities + upcoming cities)
|
||||
Future<CityList> fetchFirstTimeUserHome() async {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.cityList,
|
||||
);
|
||||
|
||||
return CityList.fromJson(response.data);
|
||||
}
|
||||
|
||||
/// If you only want Upcoming Cities
|
||||
Future<List<UpcomingCities>> fetchUpcomingCities() async {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.cityList,
|
||||
);
|
||||
|
||||
final cityList = CityList.fromJson(response.data);
|
||||
return cityList.upcomingCities ?? [];
|
||||
}
|
||||
|
||||
Future<List<Cities>> fetchCities() async {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.cityList,
|
||||
);
|
||||
|
||||
final cityList = CityList.fromJson(response.data);
|
||||
return cityList.cities ?? [];
|
||||
}
|
||||
}
|
||||
20
lib/home/repository/home_repository.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:citycards_customer/localPreference/local_preference.dart';
|
||||
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../model/home_model.dart';
|
||||
|
||||
class HomeRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
Future<HomeModel> fetchHomeData() async {
|
||||
|
||||
final int cityId = await LocalPreference.getSelectedCityId();
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.home}/$cityId',
|
||||
);
|
||||
|
||||
return HomeModel.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
38
lib/home/repository/search_city_repository.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../model/city_selection_model.dart';
|
||||
|
||||
class SearchCityRepository {
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
|
||||
Future<CitySelectionResponse> fetchAllCities() async {
|
||||
try {
|
||||
final response = await _apiServices.getApi(
|
||||
url: ApiUrls.searchCityList,
|
||||
);
|
||||
return CitySelectionResponse.fromJson(response.data);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch cities: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Search cities by query
|
||||
Future<List<CitySelection>> searchCities(String query) async {
|
||||
try {
|
||||
final response = await fetchAllCities();
|
||||
|
||||
if (query.isEmpty) {
|
||||
return response.cities;
|
||||
}
|
||||
|
||||
return response.cities
|
||||
.where((city) =>
|
||||
city.cityName.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to search cities: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import '../../common_packages/app_bar.dart';
|
||||
import '../../localPreference/local_preference.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../widgets/explore_cities_card.dart';
|
||||
import '../bloc/FirstTimeUserHome/first_time_user_home_bloc.dart';
|
||||
import '../bloc/FirstTimeUserHome/first_time_user_home_event.dart';
|
||||
import '../bloc/FirstTimeUserHome/first_time_user_home_state.dart';
|
||||
|
||||
class FirstTimeUserHomePage extends StatefulWidget {
|
||||
final VoidCallback onContinue;
|
||||
const FirstTimeUserHomePage({super.key, required this.onContinue});
|
||||
const FirstTimeUserHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<FirstTimeUserHomePage> createState() => _FirstTimeUserHomePageState();
|
||||
@@ -17,48 +22,11 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
|
||||
double _scrollProgress = 0.0;
|
||||
|
||||
final List<Map<String, String>> featuredCities = [
|
||||
{
|
||||
"name": "Melbourne",
|
||||
"description": "Australia's cultural capital famous for vibrant...",
|
||||
"individualTicket": "\$350+",
|
||||
"cityCard": "\$199",
|
||||
"savings": "Save \$151+",
|
||||
"image": "assets/images/city_sydney.png",
|
||||
},
|
||||
{
|
||||
"name": "Sydney",
|
||||
"description": "Australia's cultural capital famous for vibrant...",
|
||||
"individualTicket": "\$400+",
|
||||
"cityCard": "\$249",
|
||||
"savings": "Save \$151+",
|
||||
"image": "assets/images/city_sydney.png",
|
||||
},
|
||||
{
|
||||
"name": "Sydney",
|
||||
"description": "Australia's cultural capital famous for vibrant...",
|
||||
"individualTicket": "\$400+",
|
||||
"cityCard": "\$249",
|
||||
"savings": "Save \$151+",
|
||||
"image": "assets/images/city_sydney.png",
|
||||
},
|
||||
];
|
||||
|
||||
final List<Map<String, String>> upcomingCities = [
|
||||
{"image": "assets/images/city_turkey.jpg", "name": "Turkey"},
|
||||
{"image": "assets/images/city_germany.jpg", "name": "Germany"},
|
||||
{"image": "assets/images/city_switz.jpg", "name": "Switzerland"},
|
||||
{"image": "assets/images/city_maldives.jpg", "name": "Maldives"},
|
||||
{"image": "assets/images/city_turkey.jpg", "name": "Turkey"},
|
||||
{"image": "assets/images/city_germany.jpg", "name": "Germany"},
|
||||
{"image": "assets/images/city_switz.jpg", "name": "Switzerland"},
|
||||
{"image": "assets/images/city_maldives.jpg", "name": "Maldives"},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_updateScrollProgress);
|
||||
context.read<FirstTimeUserHomeBloc>().add(FetchFirstTimeUserHomeEvent());
|
||||
}
|
||||
|
||||
void _updateScrollProgress() {
|
||||
@@ -68,7 +36,7 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
setState(() {
|
||||
_scrollProgress =
|
||||
(_scrollController.offset /
|
||||
_scrollController.position.maxScrollExtent)
|
||||
_scrollController.position.maxScrollExtent)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
}
|
||||
@@ -79,6 +47,20 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleGetCityCard() async {
|
||||
// Update onboarding page from 1 to 2
|
||||
await LocalPreference.updateOnboardingPage(2);
|
||||
print('✅ Onboarding page updated from 1 to 2');
|
||||
|
||||
if (mounted) {
|
||||
// Navigate to regular home screen
|
||||
Navigator.pushReplacementNamed(
|
||||
context,
|
||||
RouteConstants.home,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
@@ -121,7 +103,7 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
borderRadius: BorderRadius.circular(25.r),
|
||||
),
|
||||
),
|
||||
onPressed: widget.onContinue,
|
||||
onPressed: _handleGetCityCard,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -164,25 +146,86 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
),
|
||||
SizedBox(height: 16.sp),
|
||||
|
||||
// Horizontal cards
|
||||
SizedBox(
|
||||
height: 270.h,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: featuredCities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final city = featuredCities[index];
|
||||
return ExploreCitiesCard(
|
||||
name: city['name']!,
|
||||
description: city['description']!,
|
||||
imageUrl: city['image']!,
|
||||
individualPrice: city['individualTicket']!,
|
||||
cityCardPrice: city['cityCard']!,
|
||||
savingsText: city['savings']!,
|
||||
// Explore Cities - Using BLoC
|
||||
BlocBuilder<FirstTimeUserHomeBloc, FirstTimeUserHomeState>(
|
||||
builder: (context, state) {
|
||||
if (state is FirstTimeUserHomeLoading) {
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeError) {
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeLoaded) {
|
||||
final cities = state.cities;
|
||||
|
||||
if (cities.isEmpty) {
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: const Center(
|
||||
child: Text('No cities available'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 270.h,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: cities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final city = cities[index];
|
||||
|
||||
// Construct image URL with fallback
|
||||
final imageUrl = city.bannerImage != null && city.bannerImage!.isNotEmpty
|
||||
? city.bannerImage!
|
||||
: 'assets/images/city_sydney.png';
|
||||
|
||||
// Determine if it's a network image or asset
|
||||
final isNetworkImage = imageUrl.startsWith('http');
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await LocalPreference.updateOnboardingPage(2);
|
||||
await LocalPreference.setSelectedCityId(city.id!);
|
||||
Navigator.pushReplacementNamed(
|
||||
context,
|
||||
RouteConstants.home,
|
||||
);
|
||||
},
|
||||
child: ExploreCitiesCard(
|
||||
name: city.cityName ?? 'N/A',
|
||||
description: city.tagLine ?? 'N/A',
|
||||
imageUrl: imageUrl,
|
||||
individualPrice: '\$${city.indivisualTicketAmt ?? 0}+',
|
||||
cityCardPrice: '\$${city.cityCardTicketAmt ?? 0}',
|
||||
savingsText: city.saveLabel ?? 'Save \$0+',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
@@ -232,30 +275,78 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
SizedBox(
|
||||
height: 80.h,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: upcomingCities.length,
|
||||
separatorBuilder: (_, __) => SizedBox(width: 16.w),
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28.r,
|
||||
backgroundImage: AssetImage(
|
||||
upcomingCities[index]["image"] ?? "",
|
||||
),
|
||||
|
||||
// Upcoming Cities - Using BLoC
|
||||
BlocBuilder<FirstTimeUserHomeBloc, FirstTimeUserHomeState>(
|
||||
builder: (context, state) {
|
||||
if (state is FirstTimeUserHomeLoading) {
|
||||
return SizedBox(
|
||||
height: 80.h,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
upcomingCities[index]["name"] ?? "",
|
||||
style: TextStyle(fontSize: 12.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeError) {
|
||||
return SizedBox(
|
||||
height: 80.h,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is FirstTimeUserHomeLoaded) {
|
||||
final upcomingCities = state.upcomingCities;
|
||||
|
||||
if (upcomingCities.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 80.h,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: upcomingCities.length,
|
||||
separatorBuilder: (_, __) => SizedBox(width: 16.w),
|
||||
itemBuilder: (context, index) {
|
||||
final city = upcomingCities[index];
|
||||
final imageUrl =
|
||||
'${ApiUrls.baseUrl}${city.imgPathName}';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28.r,
|
||||
backgroundImage: NetworkImage(imageUrl),
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
SizedBox(
|
||||
width: 60.w,
|
||||
child: Text(
|
||||
city.cityName ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12.sp),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -266,4 +357,4 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||