16 Commits

Author SHA1 Message Date
mystery012728
e91d24becc pull taken of shreeyash and conflict solved 2026-02-09 10:55:36 +05:30
Shreeyash Thorat
09726eb4e6 API Integration 2026-02-06 19:34:34 +05:30
mystery012728
10eae3577f added payment api for passes and more 2026-02-06 19:01:49 +05:30
mystery012728
460f553aee added payment api for passes and more 2026-02-06 18:58:58 +05:30
mystery012728
a7548ccebd added apply coupon with api intigration and more fixes 2026-02-05 19:35:01 +05:30
mystery012728
c2ffc9d9a7 my Post Cards added with get api and more changes 2026-02-05 12:07:33 +05:30
mystery012728
082bb9b74a razer pay added and more chnages added 2026-01-30 19:27:06 +05:30
mystery012728
fa4f78bceb added offers , offer details and pass details api and more chnages 2026-01-29 19:32:11 +05:30
mystery012728
0434b16bde added userdetails api get and put and more changes 2026-01-28 19:28:37 +05:30
mystery012728
1cb344738e refresh token api integreted and isLogin created in local storages 2026-01-27 18:47:15 +05:30
mystery012728
f5782f6da1 api integrtaion send Otp ,verify otp and faq , Terms ,Policy 2026-01-23 19:00:55 +05:30
mystery012728
bbb96512d1 added local preferance for selectCityID and more fixes 2026-01-21 19:02:23 +05:30
mystery012728
a55510a482 Api Integrated in regitsered home page and in attarction and attraction details pages and there are chnages are there from backend they are pending. 2026-01-19 19:10:14 +05:30
mystery012728
d3abf4053a Added api of upcoming cities and cities and selection cities 2026-01-16 19:18:42 +05:30
mystery012728
aac65c57be city_list_model added 2026-01-16 12:32:24 +05:30
mystery012728
c62c725410 added retry hit api after timeouts 2026-01-12 12:48:59 +05:30
206 changed files with 20620 additions and 6408 deletions

View File

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

View File

@@ -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
View 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.**

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
assets/images/not_login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -0,0 +1,116 @@
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<ResetPaymentState>(_onResetPaymentState);
}
Future<void> _onInitiatePayment(
InitiatePayment event,
Emitter<StripePaymentState> emit,
) async {
try {
emit(const StripePaymentLoading());
/// 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,
);
// 2⃣ Init Payment Sheet
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: clientSecret,
merchantDisplayName: "CityCards",
style: ThemeMode.light,
),
);
// 3⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
// Handle Stripe-specific errors
if (e.error.code == FailureCode.Canceled) {
emit(StripePaymentCancelled(
message: e.error.localizedMessage ?? 'Payment Cancelled',
));
} else {
emit(StripePaymentFailure(
error: e.error.localizedMessage ?? 'Payment failed',
));
}
} catch (e) {
emit(StripePaymentFailure(
error: e.toString(),
));
}
}
/// 🆕 NEW: Handle payment with clientSecret directly from backend
Future<void> _onInitiatePaymentWithClientSecret(
InitiatePaymentWithClientSecret event,
Emitter<StripePaymentState> emit,
) async {
try {
emit(const StripePaymentLoading());
// 1⃣ Init Payment Sheet with clientSecret from backend
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
paymentIntentClientSecret: event.clientSecret,
merchantDisplayName: "CityCards",
style: ThemeMode.light,
),
);
// 2⃣ Show Payment Sheet
await Stripe.instance.presentPaymentSheet();
// ✅ SUCCESS
emit(const StripePaymentSuccess());
} on StripeException catch (e) {
// Handle Stripe-specific errors
if (e.error.code == FailureCode.Canceled) {
emit(StripePaymentCancelled(
message: e.error.localizedMessage ?? 'Payment Cancelled',
));
} else {
emit(StripePaymentFailure(
error: e.error.localizedMessage ?? 'Payment failed',
));
}
} catch (e) {
emit(StripePaymentFailure(
error: e.toString(),
));
}
}
void _onResetPaymentState(
ResetPaymentState event,
Emitter<StripePaymentState> emit,
) {
emit(const StripePaymentInitial());
}
}

View File

@@ -0,0 +1,37 @@
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];
}
/// 🆕 NEW: 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];
}
class ResetPaymentState extends StripePaymentEvent {
const ResetPaymentState();
}

View File

@@ -0,0 +1,49 @@
import 'package:equatable/equatable.dart';
abstract class StripePaymentState extends Equatable {
const StripePaymentState();
@override
List<Object?> get props => [];
}
class StripePaymentInitial extends StripePaymentState {
const StripePaymentInitial();
}
class StripePaymentLoading extends StripePaymentState {
const StripePaymentLoading();
}
class StripePaymentSuccess extends StripePaymentState {
final String message;
const StripePaymentSuccess({
this.message = 'Payment Successful',
});
@override
List<Object?> get props => [message];
}
class StripePaymentFailure extends StripePaymentState {
final String error;
const StripePaymentFailure({
required this.error,
});
@override
List<Object?> get props => [error];
}
class StripePaymentCancelled extends StripePaymentState {
final String message;
const StripePaymentCancelled({
this.message = 'Payment Cancelled',
});
@override
List<Object?> get props => [message];
}

View 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');
}
}
}
*/

View File

@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/stripe_payment_bloc.dart';
import '../bloc/stripe_payment_event.dart';
import '../bloc/stripe_payment_state.dart';
import '../repository/stripe_service.dart';
class StripePaymentView extends StatelessWidget {
const StripePaymentView({super.key});
@override
Widget build(BuildContext context) {
final args =
ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
final double amount = args['amount'];
final String currency = args['currency'];
return BlocProvider(
create: (context) => StripePaymentBloc(
stripeService: StripeService(),
),
child: StripePaymentViewContent(
amount: amount,
currency: currency,
),
);
}
}
class StripePaymentViewContent extends StatefulWidget {
final double amount;
final String currency;
const StripePaymentViewContent({
super.key,
required this.amount,
required this.currency,
});
@override
State<StripePaymentViewContent> createState() =>
_StripePaymentViewContentState();
}
class _StripePaymentViewContentState extends State<StripePaymentViewContent> {
@override
void initState() {
super.initState();
// Automatically initiate payment when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<StripePaymentBloc>().add(
InitiatePayment(
amount: widget.amount,
currency: widget.currency,
),
);
});
}
@override
Widget build(BuildContext context) {
return BlocListener<StripePaymentBloc, StripePaymentState>(
listener: (context, state) {
if (state is StripePaymentSuccess) {
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
// Return success to previous screen
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.pop(context, true);
}
});
} else if (state is StripePaymentFailure) {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
// Go back to checkout on error
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
Navigator.pop(context, false);
}
});
} else if (state is StripePaymentCancelled) {
// Show cancellation message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
// Go back to checkout on cancellation
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.pop(context, false);
}
});
}
},
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text("Processing Payment"),
backgroundColor: Colors.white,
elevation: 0,
automaticallyImplyLeading: false, // Remove back button during processing
centerTitle: true,
),
body: BlocBuilder<StripePaymentBloc, StripePaymentState>(
builder: (context, state) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Loading Indicator
if (state is StripePaymentLoading) ...[
const CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFFF95F62),
),
),
const SizedBox(height: 24),
const Text(
"Preparing secure payment...",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF333333),
),
),
const SizedBox(height: 12),
Text(
"Please wait",
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
// Amount Display
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFE0E0E0),
),
),
child: Column(
children: [
Text(
"Payment Amount",
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
"\$${widget.amount.toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
const SizedBox(height: 4),
Text(
widget.currency.toUpperCase(),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
const SizedBox(height: 32),
// Security Badge
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.lock_outline,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 6),
Text(
"Secured by Stripe",
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
],
),
),
);
},
),
),
);
}
}

View File

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

View File

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

View 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(),
),
);
}
}
}

View 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];
}

View 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];
}

View 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,
);
}
}

View File

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

View 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),
),
],
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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));
}
}
}
}

View 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);
}

View 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);
}

View 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,
};
}

View 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";
}

View File

@@ -0,0 +1,51 @@
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,
}) async {
try {
final response = await _apiService.postApi(
url: ApiUrls.addToCartPasses, // add this key in ApiUrls
data: {
"cityXid": cityXid,
"cardTypeXid": cardTypeXid,
"cardXid": cardXid,
"cardMode": cardMode,
"totalAdult": totalAdult,
"totalChild": totalChild,
"noOfAttractions": noOfAttractions,
"noOfDays": noOfDays,
},
);
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Failed to add passes to cart: $e');
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,158 +1,325 @@
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' : 'fixed',
totalAdult: adults,
totalChild: children,
noOfAttractions: isSelectivePass ? selectedValue : 0,
noOfDays: isUnlimitedCard ? selectedValue : 0,
);
// ✅ 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 +340,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),
),
);
}
}
}

View 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()));
}
}
}

View 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();
}

View 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];
}

View 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;
}
}
}

View File

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

View 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();
},
);
}
}

View File

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

View File

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

View 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()));
}
}
}

View File

@@ -0,0 +1,3 @@
abstract class AllCouponsEvent {}
class FetchAllCouponsEvent extends AllCouponsEvent {}

View 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});
}

View File

@@ -0,0 +1,185 @@
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<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));
}
}
void _onRemoveCoupon(
RemoveCouponEvent event,
Emitter<CheckoutState> emit,
) {
if (state is CheckoutCouponsLoadedState) {
final currentState = state as CheckoutCouponsLoadedState;
emit(currentState.copyWith(clearAppliedCoupon: true));
}
}
/// 🆕 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(),
));
}
}
}
}

View File

@@ -0,0 +1,34 @@
import '../../models/all_coupons_model.dart';
abstract class CheckoutEvent {}
class FetchCheckoutCouponsEvent extends CheckoutEvent {}
class ApplyCouponEvent extends CheckoutEvent {
final AllCouponsModel coupon;
ApplyCouponEvent({required this.coupon});
}
class RemoveCouponEvent extends CheckoutEvent {}
/// 🆕 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,
});
}

View File

@@ -0,0 +1,109 @@
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;
// 🆕 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.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? 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),
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});
}

View 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,
));
}
}
}

View 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,
});
}

View 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,
);
}

View File

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

View 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,
};
}
}

View 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();
}
}

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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();
},
),
),
),
],
],
),
),
);
}
}
}

View File

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

View 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),
],
),
);
},
);
}
}

View File

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

View File

@@ -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),
],
),

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,12 @@
import 'package:citycards_customer/Profile/profile_page_view.dart';
import 'package:citycards_customer/add_details/add_details_view.dart';
import 'package:citycards_customer/attraction_details/attraction_details_view.dart';
import 'package:citycards_customer/attraction_details/views/attraction_details_view.dart';
import 'package:citycards_customer/attractions/models/attraction_model.dart';
import 'package:citycards_customer/buy_a_pass/view/buy_pass_view.dart';
import 'package:citycards_customer/checkout/view/checkout_view.dart';
import 'package:citycards_customer/common_bloc/language_selection_bloc.dart';
import 'package:citycards_customer/contact_us/contact_us_view.dart';
import 'package:citycards_customer/create_account/create_account_view.dart';
import 'package:citycards_customer/edit_profile/edit_profile_view.dart';
import 'package:citycards_customer/create_account/view/create_account_view.dart';
import 'package:citycards_customer/esim_offer/esim_offer_view.dart';
import 'package:citycards_customer/faq/faq_view.dart';
import 'package:citycards_customer/hotel_offer/hotel_offer_view.dart';
import 'package:citycards_customer/intro_screens/views/intro_screen_view.dart';
import 'package:citycards_customer/itinerary_creation/bloc/itinerary_detail_bloc.dart';
@@ -16,13 +14,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 +28,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 +150,10 @@ class AppRouter {
);
case RouteConstants.attractionDetails:
final attractionId = settings.arguments as Attraction;
return MaterialPageRoute(
builder: (_) {
return AttractionDetailsView();
return AttractionDetailsView(attractionId: attractionId.id,);
},
);
@@ -160,12 +165,15 @@ class AppRouter {
);
case RouteConstants.checkout:
final bookingId = settings.arguments as int; // or String
return MaterialPageRoute(
builder: (_) {
return CheckoutView();
},
builder: (_) => CheckoutView(
bookingId: bookingId,
),
);
case RouteConstants.cartPage:
return MaterialPageRoute(
builder: (_) {
@@ -177,23 +185,32 @@ 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,17 +231,20 @@ class AppRouter {
case RouteConstants.magicItineraryFilledScreen:
return MaterialPageRoute(
builder: (_) {
return MagicItineraryFilledView();
return MagicItineraryView();
},
);
case RouteConstants.offerPassDetail:
final offerId = settings.arguments as int;
return MaterialPageRoute(
builder: (_) {
return OfferPassDetailView();
},
builder: (_) => OffersDetailsView(
offerId: offerId,
),
);
case RouteConstants.registeredUserHome:
return MaterialPageRoute(
builder: (_) {

View File

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

View 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());
}
}

View 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();
}

View 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];
}

View File

@@ -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")
],
),
),
),
);
}
}

View 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,
};
}
}

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

View 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),
],
),
),
),
],
),
),
),
),
);
}
}

View File

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

View File

@@ -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(),
),
],
),
);
}

View File

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

View File

@@ -0,0 +1,3 @@
abstract class FirstTimeUserHomeEvent {}
class FetchFirstTimeUserHomeEvent extends FirstTimeUserHomeEvent {}

View File

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

View File

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

View 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()));
}
}
}

View File

@@ -0,0 +1,3 @@
abstract class HomeEvent {}
class FetchHomeData extends HomeEvent {}

View 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);
}

View File

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

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

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

View 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 ?? [];
}
}

View 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);
}
}

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'package:citycards_customer/itinerary_creation/views/magic_itinerary_view.dart';
import 'package:citycards_customer/postcard/views/my_postcards_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:citycards_customer/common_packages/custom_bottom_navbar.dart';
@@ -6,8 +8,7 @@ import 'package:citycards_customer/itinerary_creation/views/itinerary_creation_s
import 'package:citycards_customer/my_pass/views/my_pass_page_view.dart';
import 'package:citycards_customer/postcard/views/postcard_initial_page_view.dart';
import '../../common_bloc/bottom_navigation_bloc.dart';
import '../bloc/app_start_bloc.dart';
import 'first_time_user_home_page.dart';
import '../../itinerary_creation/views/magic_itinerary_empty_view.dart';
import 'registered_user_home_page.dart';
class HomePage extends StatefulWidget {
@@ -18,60 +19,49 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> {
final _navigatorKeys = [
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
];
final _navigatorKeys = List.generate(4, (_) => GlobalKey<NavigatorState>());
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AppStartBloc()..add(CheckFirstTimeUser()),
child: BlocBuilder<AppStartBloc, AppStartState>(
builder: (context, state) {
if (state is AppStartLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, navState) {
final currentIndex = navState.selectedIndex;
if (state is AppStartFirstTime) {
return FirstTimeUserHomePage(
onContinue: () {
context.read<AppStartBloc>().add(MarkUserAsRegistered());
},
);
}
// Once registered → show normal main home tabs
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, navState) {
final currentIndex = navState.selectedIndex;
return SafeArea(
top: false,
child: Scaffold(
body: Stack(
children: [
buildOffstageNavigator(0, currentIndex,
const RegisteredUserHomePage(), _navigatorKeys[0]),
buildOffstageNavigator(1, currentIndex,
const ItineraryCreationStartPage(), _navigatorKeys[1]),
buildOffstageNavigator(2, currentIndex,
const MyPassesView(), _navigatorKeys[2]),
buildOffstageNavigator(3, currentIndex,
const PostcardPage(), _navigatorKeys[3]),
],
),
bottomNavigationBar: const CustomBottomNavBar(),
return SafeArea(
top: false,
child: Scaffold(
body: Stack(
children: [
buildOffstageNavigator(
0,
currentIndex,
const RegisteredUserHomePage(),
_navigatorKeys[0],
),
);
},
);
},
),
buildOffstageNavigator(
1,
currentIndex,
const MagicItineraryView(),
_navigatorKeys[1],
),
buildOffstageNavigator(
2,
currentIndex,
const MyPassesView(),
_navigatorKeys[2],
),
buildOffstageNavigator(
3,
currentIndex,
const MyPostCardsView(),
_navigatorKeys[3],
),
],
),
bottomNavigationBar: const CustomBottomNavBar(),
),
);
},
);
}
}
}

View File

@@ -4,15 +4,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../common_bloc/bottom_navigation_bloc.dart';
import '../../common_packages/app_bar.dart';
import '../../core/route_constants.dart';
import '../../localPreference/local_preference.dart';
import '../../networkApiServices/api_urls.dart';
import '../../postcard/blocs/myPostCards/my_postcard_bloc.dart';
import '../../postcard/blocs/myPostCards/my_postcard_event.dart';
import '../../profile/bloc/profile/profile_bloc.dart';
import '../../profile/bloc/profile/profile_event.dart';
import '../bloc/registeredHome/home_bloc.dart';
import '../bloc/registeredHome/home_event.dart';
import '../bloc/registeredHome/home_state.dart';
import '../widgets/attractions_list.dart';
import '../widgets/get_your_pass_card.dart';
import '../widgets/gradient_container_bg.dart';
import '../widgets/itineary_animation.dart';
import '../widgets/pass_card_list.dart';
import '../widgets/search_city_bottomsheet.dart';
class RegisteredUserHomePage extends StatefulWidget {
const RegisteredUserHomePage({super.key});
@@ -22,223 +31,305 @@ class RegisteredUserHomePage extends StatefulWidget {
}
class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
final List<Map<String, String>> attractions = [
{
'title': 'Koh Rong Samloemr',
'subtitle': 'Lorem ipsum dolor sit amet...',
'image': 'assets/images/koh_rong.png',
},
{
'title': 'Long-Tail Boat Charter',
'subtitle': 'Lorem ipsum dolor sit amet...',
'image': 'assets/images/clock.png',
},
{
'title': 'Koh Rong Samloemr',
'subtitle': 'Lorem ipsum dolor sit amet...',
'image': 'assets/images/koh_rong.png',
},
{
'title': 'Long-Tail Boat Charter',
'subtitle': 'Lorem ipsum dolor sit amet...',
'image': 'assets/images/clock.png',
},
];
@override
@override
void initState() {
super.initState();
// _loadMyPostCards();
_checkAndShowCitySelection();
_loadProfileIfLoggedIn();
}
Future<void> _loadProfileIfLoggedIn() async {
final userId = await LocalPreference.getUserId();
if (userId != null && mounted) {
context.read<ProfileBloc>().add(
FetchProfileEvent(userId: userId),
);
}
}
Future<void> _loadMyPostCards() async {
final userId = await LocalPreference.getUserId();
if (userId != null && mounted) {
context.read<MyPostCardBloc>().add(FetchDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
context.read<MyPostCardBloc>().add(FetchOrderPostCards());
}
}
Future<void> _checkAndShowCitySelection() async {
final int cityId = await LocalPreference.getSelectedCityId();
// If cityId is 1 (default) or invalid, show city selection
if (cityId == 0) {
// Use addPostFrameCallback to show bottom sheet after build is complete
WidgetsBinding.instance.addPostFrameCallback((_) {
_showCitySelectionBottomSheet();
});
} else {
// Load home data only if city is already selected
if (mounted) {
context.read<HomeBloc>().add(FetchHomeData());
}
}
}
void _showCitySelectionBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
isDismissible: false, // Prevent dismissing without selecting a city
enableDrag: false, // Prevent dragging to close
builder: (_) => const CitySelectionBottomSheet(),
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: SingleChildScrollView(
child: Stack(
children: [
Image.asset(
"assets/images/chicago.png",
height: 300.h,
width: double.infinity,
fit: BoxFit.cover,
),
child: BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
if (state is HomeLoading) {
return const Center(child: CircularProgressIndicator());
}
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
if (state is HomeError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${state.message}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<HomeBloc>().add(FetchHomeData());
},
child: const Text('Retry'),
),
],
),
);
}
if (state is HomeLoaded) {
final city = state.homeModel.city;
final attractions = state.homeModel.attraction ?? [];
final String? cityIconUrl =
city?.cityIconPath != null && city!.cityIconPath!.isNotEmpty
? "${ApiUrls.baseUrl}${city.cityIconPath}"
: null;
final bannerImageUrl = city?.cityBanners?.isNotEmpty == true
? city!.cityBanners!.firstWhere(
(banner) => banner.isActive == true && banner.imageFilePath != null,
orElse: () => city.cityBanners!.first,
).imageFilePath
: null;
return SingleChildScrollView(
child: Stack(
children: [
// Background image - use city banner if available
_buildBannerImage(bannerImageUrl),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: false,
),
SizedBox(height: 60.h),
Text(
"Melbourne",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 44,
),
),
SizedBox(height: 4.h),
Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
"Cras posuere, nisl id dictum consequat, elit enim tincidunt magna...",
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.h),
// Category tags
Wrap(
spacing: 8,
children: [
_buildTag("Food"),
_buildTag("Drinks"),
_buildTag("Culture"),
_buildTag("Souvenirs"),
],
),
SizedBox(height: 60.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text.rich(
TextSpan(
children: const [
TextSpan(
text: "Popular ",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Color(0xffF95F62),
),
),
TextSpan(
text: "Attractions",
style: TextStyle(
fontSize: 18,
color: Colors.black,
fontWeight: FontWeight.w500,
),
),
],
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: false,
imageUrl: cityIconUrl,
isSelectCity: true,
),
),
InkWell(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.attractionsPage,
arguments: "home",
);
},
child: Text(
"View all",
style: TextStyle(
fontSize: 12,
SizedBox(height: 60.h),
// City name from API
Text(
city?.cityName ?? "City Name",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
color: Color(0xffF95F62),
fontSize: 44,
),
),
),
],
),
const SizedBox(height: 12),
AttractionsListView(attractions: attractions),
],
),
),
InwardCurvedContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 40.h),
const ItineraryVideo(),
SizedBox(height: 20.h),
SizedBox(height: 4.h),
// 🔘 Button section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: 200,
child: ElevatedButton(
onPressed: () {
context.read<NavigationBloc>().add(NavigationTabChanged(1));
// Navigator.of(
// context,
// ).pushNamed(RouteConstants.buyPass);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
// City description from API
Text(
city?.description ?? "City description",
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 12,
fontWeight: FontWeight.w400,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
SizedBox(height: 12.h),
// Category tags - you can customize this based on your needs
Wrap(
spacing: 8,
runSpacing: 8,
children: (city?.cityHighlights ?? [])
.where((highlight) => highlight.isActive == true)
.map(
(highlight) => _buildTag(
highlight.title ?? "",
),
)
.toList(),
),
SizedBox(height: 60.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Create my iternary",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w500,
fontSize: 14.sp,
color: Colors.white,
Text.rich(
TextSpan(
children: const [
TextSpan(
text: "Popular ",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Color(0xffF95F62),
),
),
TextSpan(
text: "Attractions",
style: TextStyle(
fontSize: 18,
color: Colors.black,
fontWeight: FontWeight.w500,
),
),
],
),
),
InkWell(
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.attractionsPage,
arguments: "home",
);
},
child: const Text(
"View all",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xffF95F62),
),
),
),
const SizedBox(width: 4),
Icon(Icons.arrow_forward, color: Colors.white),
],
),
),
const SizedBox(height: 12),
// Pass attractions from API
AttractionsListView(attractions: attractions),
],
),
),
InwardCurvedContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 40.h),
const ItineraryVideo(),
SizedBox(height: 20.h),
// Button section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: 200,
child: ElevatedButton(
onPressed: () {
context.read<NavigationBloc>().add(NavigationTabChanged(1));
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Create my itinerary",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w500,
fontSize: 14.sp,
color: Colors.white,
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward, color: Colors.white),
],
),
),
),
),
],
),
),
ESimOfferSection(),
HotelOffersSection(),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
InkWell(
onTap: () {
Navigator.of(context).pushNamed(RouteConstants.searchOffer);
},
child: _buildFeatureCard(
image: "assets/images/claim_offers_bg.jpg",
title: "Claim offers with your City Cards",
subtitle: "Lorem ipsum dolor sit amet...",
),
),
],
),
const SizedBox(height: 24),
ChooseYourPassSection(
cards: state.homeModel.city?.cards ?? [],
),
const SizedBox(height: 20),
GetYourPassCard(),
],
),
),
],
),
),
ESimOfferSection(),
HotelOffersSection(),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
InkWell(
onTap: () {
Navigator.of(
context,
).pushNamed(RouteConstants.searchOffer);
},
child: _buildFeatureCard(
image: "assets/images/claim_offers_bg.jpg",
title: "Claim offers with your City Cards",
subtitle: "Lorem ipsum dolor sit amet...",
),
),
],
),
],
),
);
}
const SizedBox(height: 24),
ChooseYourPassSection(),
const SizedBox(height: 20),
GetYourPassCard(),
],
),
),
],
),
],
),
// Initial state
return const Center(child: CircularProgressIndicator());
},
),
);
}
@@ -247,12 +338,12 @@ class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Color(0xffFFFFFF).withOpacity(0.29),
color: const Color(0xffFFFFFF).withOpacity(0.29),
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: TextStyle(
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 12,
@@ -315,10 +406,7 @@ class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
],
),
),
const SizedBox(width: 8),
// Right side arrow button
Container(
decoration: const BoxDecoration(
color: Color(0xffFDCDCE),
@@ -337,4 +425,47 @@ class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
],
);
}
}
Widget _buildBannerImage(String? imageUrl) {
if (imageUrl == null || imageUrl.isEmpty) {
// Use placeholder if no image URL
return Image.asset(
"assets/images/chicago.png",
height: 300.h,
width: double.infinity,
fit: BoxFit.cover,
);
}
return Image.network(
imageUrl,
height: 300.h,
width: double.infinity,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
height: 300.h,
width: double.infinity,
color: Colors.grey[300],
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
// Use placeholder on error
return Image.asset(
"assets/images/chicago.png",
height: 300.h,
width: double.infinity,
fit: BoxFit.cover,
);
},
);
}
}

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