added check in and Qr scaner and bookng with api and more fixes and changes

This commit is contained in:
2026-03-17 17:07:14 +05:30
parent adc737a6af
commit 177f891a31
62 changed files with 2876 additions and 1335 deletions

View File

@@ -2,9 +2,11 @@ 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:country_code_picker/country_code_picker.dart'; // ✅ NEW IMPORT
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:phone_numbers_parser/phone_numbers_parser.dart'; // ✅ NEW IMPORT
import '../checkout/bloc/pass_purchase_details_bloc.dart';
import '../checkout/bloc/pass_purchase_details_event.dart';
@@ -25,13 +27,16 @@ class _AddDetailsViewState extends State<AddDetailsView> {
final TextEditingController emailController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
final TextEditingController cityController = TextEditingController();
String? selectedCountry;
final TextEditingController countryController = TextEditingController();
String _selectedIsdCode = '+61'; // ✅ NEW: tracks selected country dial code
@override
void dispose() {
firstNameController.dispose();
lastNameController.dispose();
emailController.dispose();
countryController.dispose();
phoneController.dispose();
cityController.dispose();
super.dispose();
@@ -42,22 +47,26 @@ class _AddDetailsViewState extends State<AddDetailsView> {
return emailRegex.hasMatch(email);
}
// ✅ UPDATED: now validates phone using phone_numbers_parser against the selected ISD code
bool _isValidPhone(String phone) {
final phoneRegex = RegExp(r'^[0-9]{10}$');
return phoneRegex.hasMatch(phone);
try {
final fullNumber = '$_selectedIsdCode$phone';
final parsed = PhoneNumber.parse(fullNumber);
return parsed.isValid();
} catch (_) {
return false;
}
}
void _handleSubmit(BuildContext context, bool isSubmitting) {
// If already submitting, do nothing
if (isSubmitting) return;
// Validate inputs
if (firstNameController.text.isEmpty ||
lastNameController.text.isEmpty ||
emailController.text.isEmpty ||
phoneController.text.isEmpty ||
cityController.text.isEmpty ||
selectedCountry == null) {
countryController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fill all fields'),
@@ -67,7 +76,6 @@ class _AddDetailsViewState extends State<AddDetailsView> {
return;
}
// Validate email
if (!_isValidEmail(emailController.text)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -78,28 +86,28 @@ class _AddDetailsViewState extends State<AddDetailsView> {
return;
}
// Validate phone number
// ✅ UPDATED: error message now shows the selected ISD code
if (!_isValidPhone(phoneController.text)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter a valid 10-digit phone number'),
SnackBar(
content: Text('Enter a valid phone number for $_selectedIsdCode'),
backgroundColor: Colors.red,
),
);
return;
}
// Submit gift details
context.read<PurchaseDetailsBloc>().add(
SubmitUserDetailsEvent(
bookingId: widget.bookingId,
isForSelf: false,
recipientFirstName: firstNameController.text,
recipientLastName: lastNameController.text,
isdCode: _selectedIsdCode,
recipientEmail: emailController.text,
recipientPhone: phoneController.text,
city: cityController.text,
country: selectedCountry!,
country: countryController.text,
),
);
}
@@ -110,21 +118,10 @@ class _AddDetailsViewState extends State<AddDetailsView> {
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(
@@ -213,16 +210,44 @@ class _AddDetailsViewState extends State<AddDetailsView> {
keyboardType: TextInputType.emailAddress,
),
),
// ✅ NEW: Phone field with CountryCodePicker (replaces plain CustomTextField)
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Phone Number *",
hint: "Enter recipient's phone number",
hint: "Enter phone number",
controller: phoneController,
maxLength: 10,
keyboardType: TextInputType.number,
keyboardType: TextInputType.phone,
maxLength: 12,
numbersOnly: true,
prefixWidget: CountryCodePicker(
onChanged: (country) {
setState(() => _selectedIsdCode = country.dialCode!);
},
initialSelection: 'AU',
favorite: const ['+61', '+1', '+44', '+91'],
showCountryOnly: false,
showOnlyCountryWhenClosed: false,
alignLeft: false,
flagWidth: 24.w,
padding: EdgeInsets.symmetric(horizontal: 8.w),
textStyle: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF2D3134),
),
dialogTextStyle: TextStyle(fontSize: 14.sp),
searchDecoration: const InputDecoration(
hintText: 'Search country...',
prefixIcon: Icon(Icons.search),
),
),
),
),
// ✅ END of new phone field
SizedBox(height: 8.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
@@ -236,67 +261,19 @@ class _AddDetailsViewState extends State<AddDetailsView> {
),
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Country *", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountry,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select country",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedCountry = value;
});
},
items: ["Australia"]
.map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Country *",
hint: "Enter country name",
controller: countryController,
maxLength: 50,
onlyLetters: true,
isFirstLetterCapital: true,
),
),
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",

View File

@@ -116,7 +116,7 @@ class _BuyPassContentState extends State<BuyPassContent> {
children: [
const Icon(Icons.arrow_back),
SizedBox(width: 8.w),
CustomText(text: "Buy a Pass", size: 12.sp),
CustomText(text: "Buy a Card", size: 12.sp),
],
),
),

View File

@@ -80,6 +80,7 @@ class PurchaseDetailsBloc
isForSelf: event.isForSelf,
recipientFirstName: event.recipientFirstName,
recipientLastName: event.recipientLastName,
isdCode: event.isdCode,
recipientEmail: event.recipientEmail,
recipientPhone: event.recipientPhone,
city: event.city,

View File

@@ -19,6 +19,7 @@ class SubmitUserDetailsEvent extends PassPurchaseDetailsEvent {
final bool isForSelf;
final String? recipientFirstName;
final String? recipientLastName;
final String? isdCode;
final String? recipientEmail;
final String? recipientPhone;
final String? city;
@@ -29,6 +30,7 @@ class SubmitUserDetailsEvent extends PassPurchaseDetailsEvent {
required this.isForSelf,
this.recipientFirstName,
this.recipientLastName,
this.isdCode,
this.recipientEmail,
this.recipientPhone,
this.city,

View File

@@ -1,6 +1,3 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
@@ -8,63 +5,34 @@ 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? isdCode,
String? recipientEmail,
String? recipientPhone,
String? city,
String? country,
}) async {
try {
log('🟢 submitUserDetails() called');
log('📤 [SUBMIT USER DETAILS] Booking ID: $bookingId');
log('📤 [SUBMIT USER DETAILS] Is For Self: $isForSelf');
// Construct URL with bookingId
final url = '${ApiUrls.baseUrl}/mobile/passes/$bookingId/user-details';
if (kDebugMode) {
print('📤 [SUBMIT USER DETAILS] API URL: $url');
}
// Request body
final requestBody = {
'isForSelf': isForSelf,
'recipientFirstName': recipientFirstName ?? '',
'recipientLastName': recipientLastName ?? '',
'recipientEmail': recipientEmail ?? '',
'recipientPhone': recipientPhone ?? '',
'recipientCity': city ?? '',
'recipientCountry': country ?? '',
};
log('📦 Request Body: $requestBody');
// Send POST request
final response = await _apiServices.putApi(
url: url,
data: requestBody,
url: '${ApiUrls.baseUrl}/mobile/passes/$bookingId/user-details',
data: {
'isForSelf': isForSelf,
'recipientFirstName': recipientFirstName ?? '',
'recipientLastName': recipientLastName ?? '',
'isdCode': isdCode ?? '',
'recipientEmail': recipientEmail ?? '',
'recipientPhone': recipientPhone ?? '',
'recipientCity': city ?? '',
'recipientCountry': country ?? '',
},
);
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,
);
} catch (e) {
throw Exception('Failed to submit user details: $e');
}
}

View File

@@ -13,6 +13,7 @@ class CustomTextField extends StatelessWidget {
final TextInputType? keyboardType;
final bool obscureText;
final Widget? suffixIcon;
final Widget? prefixWidget; // ✅ NEW: optional prefix (e.g. CountryCodePicker)
final void Function(String)? onChanged;
final int? maxLength;
@@ -26,7 +27,7 @@ class CustomTextField extends StatelessWidget {
final bool noSpecialCharacters;
final bool isFirstLetterCapital;
final int mobileLength;
final bool isPreview; // ✅ NEW
final bool isPreview;
const CustomTextField({
super.key,
@@ -39,6 +40,7 @@ class CustomTextField extends StatelessWidget {
this.keyboardType,
this.obscureText = false,
this.suffixIcon,
this.prefixWidget, // ✅ NEW
this.onChanged,
this.maxLength,
this.numbersOnly = false,
@@ -49,7 +51,7 @@ class CustomTextField extends StatelessWidget {
this.noSpecialCharacters = false,
this.isFirstLetterCapital = false,
this.mobileLength = 10,
this.isPreview = false, // ✅ NEW
this.isPreview = false,
});
void _capitalizeFirstLetter(String value) {
@@ -68,7 +70,7 @@ class CustomTextField extends StatelessWidget {
}
String? _internalValidator(String? value) {
if (isPreview) return null; // ✅ Skip validation in preview mode
if (isPreview) return null;
if (value == null || value.trim().isEmpty) {
return 'Please enter $label';
@@ -106,7 +108,6 @@ class CustomTextField extends StatelessWidget {
Widget build(BuildContext context) {
final List<TextInputFormatter> inputFormatters = [];
// ✅ Block all input in preview mode
if (isPreview) {
inputFormatters.add(
TextInputFormatter.withFunction((oldValue, newValue) => oldValue),
@@ -144,91 +145,133 @@ class CustomTextField extends StatelessWidget {
}
}
// ✅ Determine border radius — if prefixWidget is present, only round the right side
final borderRadius = prefixWidget != null
? BorderRadius.only(
topRight: Radius.circular(8.r),
bottomRight: Radius.circular(8.r),
)
: BorderRadius.circular(8.r);
// ✅ Determine fill color
final fillColor = isPreview
? Colors.grey.shade100
: enabled
? const Color(0xFFFFF5F5)
: Colors.grey.shade200;
final textFormField = TextFormField(
controller: controller,
maxLines: obscureText ? 1 : maxLines,
enabled: isPreview ? false : enabled,
obscureText: obscureText,
validator: validator ?? _internalValidator,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: keyboardType ??
(isMobileNumber
? TextInputType.phone
: isEmail
? TextInputType.emailAddress
: TextInputType.name),
inputFormatters: inputFormatters,
onChanged: (value) {
if (isFirstLetterCapital) {
_capitalizeFirstLetter(value);
}
if (onChanged != null) {
onChanged!(value);
}
},
decoration: InputDecoration(
hintText: hint,
counterText: "",
hintStyle: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
filled: true,
fillColor: fillColor,
contentPadding: EdgeInsets.symmetric(
// ✅ Reduce left padding when prefixWidget takes up the left side
horizontal: prefixWidget != null ? 12.w : 24.w,
vertical: maxLines != null && maxLines! > 1 ? 12.h : 10.h,
),
suffixIcon: suffixIcon,
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: .4.w,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: const Color(0xFFF95F62),
width: 1.w,
),
),
errorBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Colors.red,
width: 1.w,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: Colors.red,
width: 1.5.w,
),
),
errorStyle: TextStyle(
fontSize: 11.sp,
color: Colors.red,
height: 1.3,
),
),
);
return Padding(
padding: EdgeInsets.only(bottom: 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: label,
size: 14.sp,
),
SizedBox(height: 6.h),
TextFormField(
controller: controller,
maxLines: obscureText ? 1 : maxLines,
enabled: isPreview ? false : enabled, // ✅ Disable in preview
obscureText: obscureText,
validator: validator ?? _internalValidator,
autovalidateMode: AutovalidateMode.onUserInteraction,
keyboardType: keyboardType ??
(isMobileNumber
? TextInputType.phone
: isEmail
? TextInputType.emailAddress
: TextInputType.name),
inputFormatters: inputFormatters,
onChanged: (value) {
if (isFirstLetterCapital) {
_capitalizeFirstLetter(value);
}
if (onChanged != null) {
onChanged!(value);
}
},
decoration: InputDecoration(
hintText: hint,
counterText: "",
hintStyle: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
// ✅ Only show label row if label is not empty
if (label.isNotEmpty) ...[
CustomText(text: label, size: 14.sp),
SizedBox(height: 6.h),
],
// ✅ If prefixWidget provided, wrap it in a Row with the picker on the left
if (prefixWidget != null)
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Prefix container — styled to match the field
Container(
decoration: BoxDecoration(
color: fillColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8.r),
bottomLeft: Radius.circular(8.r),
),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: prefixWidget!,
),
// TextField takes the remaining space
Expanded(child: textFormField),
],
),
filled: true,
fillColor: isPreview
? Colors.grey.shade100 // ✅ Distinct preview background
: enabled
? const Color(0xFFFFF5F5)
: Colors.grey.shade200,
contentPadding: EdgeInsets.symmetric(
horizontal: 24.w,
vertical: maxLines != null && maxLines! > 1 ? 12.h : 10.h,
),
suffixIcon: suffixIcon,
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: const Color(0xFFF95F62),
width: 1.w,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: Colors.red,
width: 1.w,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
borderSide: BorderSide(
color: Colors.red,
width: 1.5.w,
),
),
errorStyle: TextStyle(
fontSize: 11.sp,
color: Colors.red,
height: 1.3,
),
),
),
)
else
textFormField,
],
),
);

View File

@@ -98,6 +98,7 @@ class AppRouter {
case RouteConstants.passAttractionsPage:
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
final int cityId = args['cityId'] as int;
final int bookingId = args['bookingId'] as int;
final String source = args['source'] as String;
return MaterialPageRoute(
@@ -109,6 +110,7 @@ class AppRouter {
child: PassAttractionsPage(
cityXid: cityId,
source: source,
bookingId: bookingId,
),
);
},

View File

@@ -33,6 +33,7 @@ import '../networkApiServices/noInternet/view/no_internet_screen.dart';
import '../offer_pass_detail/offer_pass_detail_view.dart';
import '../postcard/blocs/postcard_creation_bloc.dart';
import '../postcard/views/postcard_creation_page_view.dart';
import '../profile/view/contact_us/contact_us_view.dart';
import '../profile/view/privacy/privacy_view.dart';
import '../search_offers/bloc/offers_bloc.dart';
import '../search_offers/bloc/search_offers_listing_bloc.dart';
@@ -81,6 +82,7 @@ Widget buildOffstageNavigator(
case RouteConstants.passAttractionsPage:
final Map<String, dynamic> args = settings.arguments as Map<String, dynamic>;
final int cityId = args['cityId'] as int;
final int bookingId = args['bookingId'] as int;
final String source = args['source'] as String;
return MaterialPageRoute(
@@ -92,6 +94,7 @@ Widget buildOffstageNavigator(
child: PassAttractionsPage(
cityXid: cityId,
source: source,
bookingId: bookingId,
),
);
},
@@ -106,28 +109,34 @@ Widget buildOffstageNavigator(
);
case RouteConstants.passAttractionDetails:
final attractionID = settings.arguments as int;
final args = settings.arguments as Map<String, dynamic>;
final attractionId = args['attractionId'] as int;
final bookingId = args['bookingId'] as int;
return MaterialPageRoute(
builder: (_) {
return PassAttractionDetailsView(attractionId: attractionID);
},
builder: (_) => PassAttractionDetailsView(
attractionId: attractionId,
bookingId: bookingId,
),
);
case RouteConstants.makeBooking:
final args = settings.arguments as Map<String, dynamic>?;
return MaterialPageRoute(
builder: (_) {
return MakeBookingView(
title: 'Koh Rong Samloem',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis.ß',
);
},
builder: (_) => MakeBookingView(
title: args?['title'] ?? '',
description: args?['description'] ?? '',
validUpto: args?['validUpto'] ?? '',
attractionId: args?['attractionId'] ?? 0,
bookingId: args?['bookingId'] ?? 0,
),
);
case RouteConstants.bookingSuccessful:
final message = settings.arguments as String;
return MaterialPageRoute(
builder: (_) {
return BookingSuccessfulPageView();
return BookingSuccessfulPageView(message: message,);
},
);
@@ -166,6 +175,12 @@ Widget buildOffstageNavigator(
return const PrivacyPolicyPage();
},
);
case RouteConstants.contactUs:
return MaterialPageRoute(
builder: (_) {
return const ContactUsPage();
},
);
// 🔹 Upload Photo Page (start of postcard creation flow)
case RouteConstants.uploadPhotoPage:

View File

@@ -25,6 +25,7 @@ class CreateAccountBloc extends Bloc<CreateAccountEvent, CreateAccountState> {
firstName: event.firstName,
lastName: event.lastName,
emailAddress: event.emailAddress,
isdCode: event.isdCode,
mobileNumber: event.mobileNumber,
address1: event.address1,
address2: event.address2,

View File

@@ -11,6 +11,7 @@ class CreateAccountSubmitted extends CreateAccountEvent {
final String firstName;
final String lastName;
final String emailAddress;
final String isdCode;
final String mobileNumber;
final String address1;
final String address2;
@@ -23,6 +24,7 @@ class CreateAccountSubmitted extends CreateAccountEvent {
required this.firstName,
required this.lastName,
required this.emailAddress,
required this.isdCode,
required this.mobileNumber,
required this.address1,
required this.address2,
@@ -37,6 +39,7 @@ class CreateAccountSubmitted extends CreateAccountEvent {
firstName,
lastName,
emailAddress,
isdCode,
mobileNumber,
address1,
address2,

View File

@@ -8,6 +8,7 @@ class CreateAccountRepository {
required String firstName,
required String lastName,
required String emailAddress,
required String isdCode,
required String mobileNumber,
required String address1,
required String address2,
@@ -23,6 +24,7 @@ class CreateAccountRepository {
"firstName": firstName,
"lastName": lastName,
"emailAddress": emailAddress,
"isdCode": isdCode,
"mobileNumber": mobileNumber,
"address1": address1,
"address2": address2,

View File

@@ -2,9 +2,14 @@ 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:country_code_picker/country_code_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:phone_numbers_parser/phone_numbers_parser.dart';
import 'package:geocoding/geocoding.dart';
import '../../cart/blocs/myPostcardsCart/my_postcards_cart_bloc.dart';
import '../../core/route_constants.dart';
import 'package:citycards_customer/my_pass/blocs/myPasses/my_passes_event.dart';
@@ -37,18 +42,51 @@ class _CreateAccountViewState extends State<CreateAccountView> {
final TextEditingController cityController = TextEditingController();
final TextEditingController postalController = TextEditingController();
String? selectedState;
String? selectedCountry;
// ── Replaced dropdowns with plain text controllers ─────────────────────────
final TextEditingController stateController = TextEditingController();
final TextEditingController countryController = TextEditingController();
// ──────────────────────────────────────────────────────────────────────────
String _selectedIsdCode = '+61';
bool _isZipLoading = false;
// ── PRIMARY geocoding: zip → city, state, country ──────────────────────────
Future<void> fetchLocationFromZip(String zip) async {
if (zip.trim().length < 4) return; // wait for a meaningful zip length
setState(() => _isZipLoading = true);
try {
List<Location> locations = await locationFromAddress(zip);
if (locations.isNotEmpty) {
List<Placemark> placemarks = await placemarkFromCoordinates(
locations.first.latitude,
locations.first.longitude,
);
final place = placemarks.first;
setState(() {
cityController.text = place.locality ?? '';
stateController.text = place.administrativeArea ?? '';
countryController.text = place.country ?? '';
});
}
} catch (e) {
debugPrint("Zip lookup failed: $e");
} finally {
if (mounted) setState(() => _isZipLoading = false);
}
}
// ──────────────────────────────────────────────────────────────────────────
void _submitForm(BuildContext context) {
// 1. Empty field check
if (firstNameController.text.trim().isEmpty ||
lastNameController.text.trim().isEmpty ||
emailController.text.trim().isEmpty ||
phoneController.text.trim().isEmpty ||
addressController.text.trim().isEmpty ||
cityController.text.trim().isEmpty ||
selectedState == null ||
selectedCountry == null ||
stateController.text.trim().isEmpty ||
countryController.text.trim().isEmpty ||
postalController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please fill all fields')),
@@ -56,17 +94,41 @@ class _CreateAccountViewState extends State<CreateAccountView> {
return;
}
// 2. Phone validation against selected country code
final phone = phoneController.text.trim();
bool isValidPhone = false;
try {
final fullNumber = '$_selectedIsdCode$phone';
final parsed = PhoneNumber.parse(fullNumber);
isValidPhone = parsed.isValid();
} catch (_) {
isValidPhone = false;
}
if (!isValidPhone) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Enter a valid phone number for $_selectedIsdCode'),
backgroundColor: Colors.red,
),
);
return;
}
// 3. Submit
context.read<CreateAccountBloc>().add(
CreateAccountSubmitted(
firstName: firstNameController.text.trim(),
lastName: lastNameController.text.trim(),
emailAddress: emailController.text.trim(),
mobileNumber: phoneController.text.trim(),
isdCode: _selectedIsdCode,
mobileNumber: phone,
address1: addressController.text.trim(),
address2: '',
city: cityController.text.trim(),
state: selectedState!,
country: selectedCountry!,
state: stateController.text.trim(),
country: countryController.text.trim(),
postalCode: postalController.text.trim(),
),
);
@@ -81,6 +143,8 @@ class _CreateAccountViewState extends State<CreateAccountView> {
addressController.dispose();
cityController.dispose();
postalController.dispose();
stateController.dispose();
countryController.dispose();
super.dispose();
}
@@ -99,15 +163,15 @@ class _CreateAccountViewState extends State<CreateAccountView> {
context.read<ProfileBloc>().add(CheckLoginStatusEvent());
context.read<MyPostCardBloc>().add(CheckLoginStatus());
context.read<GetItineraryBloc>().add(CheckLoginAndFetchItinerary());
// context.read<MyPostCardBloc>().add(FetchDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshDraftPostCards());
context.read<MyPostCardBloc>().add(RefreshOrderPostCards());
context.read<MyPassesBloc>().add(CheckLoginAndFetchPasses());
context.read<MyPostCardsCartBloc>().add(CheckLoginAndFetchPostcardsCart());
context
.read<MyPostCardsCartBloc>()
.add(CheckLoginAndFetchPostcardsCart());
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.message)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(state.message)));
} else if (state is CreateAccountFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -132,7 +196,7 @@ class _CreateAccountViewState extends State<CreateAccountView> {
),
),
/// 🔹 Scrollable content starts here
/// Scrollable content
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w),
@@ -142,9 +206,7 @@ class _CreateAccountViewState extends State<CreateAccountView> {
Row(
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
onTap: () => Navigator.pop(context),
child: const Icon(Icons.arrow_back),
),
SizedBox(width: 8.w),
@@ -201,15 +263,38 @@ class _CreateAccountViewState extends State<CreateAccountView> {
keyboardType: TextInputType.emailAddress,
),
),
// ── Phone Number ──────────────────────────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Phone Number *",
hint: "Enter your phone number",
hint: "Enter phone number",
controller: phoneController,
keyboardType: TextInputType.number,
maxLength: 10,
isMobileNumber: true,
keyboardType: TextInputType.phone,
maxLength: 12,
numbersOnly: true,
prefixWidget: CountryCodePicker(
onChanged: (country) {
setState(() => _selectedIsdCode = country.dialCode!);
},
initialSelection: 'AU',
favorite: const ['+61', '+1', '+44', '+91'],
showCountryOnly: false,
showOnlyCountryWhenClosed: false,
alignLeft: false,
flagWidth: 24.w,
padding: EdgeInsets.symmetric(horizontal: 8.w),
textStyle: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF2D3134),
),
dialogTextStyle: TextStyle(fontSize: 14.sp),
searchDecoration: const InputDecoration(
hintText: 'Search country...',
prefixIcon: Icon(Icons.search),
),
),
),
),
@@ -223,16 +308,20 @@ class _CreateAccountViewState extends State<CreateAccountView> {
SizedBox(height: 16.h),
// ── Address ───────────────────────────────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Address *",
hint: "Enter address manually or tap to search",
hint: "Enter your address",
controller: addressController,
maxLength: 50,
// noSpecialCharacters: true,
),
),
SizedBox(height: 8.h),
// ── City (unchanged) ──────────────────────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
@@ -245,144 +334,79 @@ class _CreateAccountViewState extends State<CreateAccountView> {
),
),
// State Dropdown
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "State *", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedState,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select state",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedState = value;
});
},
items: [
"New South Wales",
"Victoria",
"Queensland",
"South Australia",
"Western Australia",
"Tasmania",
"Northern Territory",
"Australian Capital Territory"
].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
// Country Dropdown
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "Country *", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountry,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
),
hint: Text(
"Select country",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: (value) {
setState(() {
selectedCountry = value;
});
},
items: ["Australia"].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
// ── State now a plain text field ────────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Zip Code *",
hint: "Enter the zip code you reside in",
controller: postalController,
keyboardType: TextInputType.number,
maxLength: 6,
label: "State *",
hint: "Enter your state",
maxLength: 50,
noSpace: true,
controller: stateController,
isFirstLetterCapital: true,
),
),
// ── Country now a plain text field ──────────────────
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Country *",
hint: "Enter your country",
maxLength: 50,
noSpace: true,
controller: countryController,
isFirstLetterCapital: true,
),
),
// ── Zip Code → auto-fills City, State, Country ────────
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: CustomTextField(
controller: postalController,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: fetchLocationFromZip,
label: 'Zip Code *',
hint: 'Enter the zip code you reside in',
),
),
if (_isZipLoading)
Padding(
padding: EdgeInsets.only(right: 12.w),
child: SizedBox(
width: 18.w,
height: 18.h,
child:
const CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFFC83B61),
),
),
),
],
),
SizedBox(height: 4.h),
Text(
"City, State & Country will auto-fill from zip",
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF8E8E8E),
),
),
],
),
),
SizedBox(height: 20.h),
BlocBuilder<CreateAccountBloc, CreateAccountState>(
builder: (context, state) {
if (state is CreateAccountLoading) {

View File

@@ -84,7 +84,7 @@ class EsimOfferPage extends StatelessWidget {
width: 350.w,
child: CustomText(
text:
"Stay Connected Instantly with Your Complimentary eSIM",
"Connect instantly with your free eSIM",
size: 22.sp,
color: Color(0xFFFFFFFF),
),
@@ -94,7 +94,7 @@ class EsimOfferPage extends StatelessWidget {
width: 350,
child: CustomText(
text:
"Because every unforgettable trip starts with seamless connectivity.",
"Every great journey begins with smooth connectivity.",
size: 14.sp,
color: Colors.white,
),
@@ -285,7 +285,7 @@ class EsimOfferPage extends StatelessWidget {
),
),
TextSpan(
text: " CityCard",
text: " CityCards",
style: TextStyle(
color: Color(0xFFF95F62),
fontSize: 21.sp,

View File

@@ -109,9 +109,9 @@ class _FirstTimeUserHomePageState extends State<FirstTimeUserHomePage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
"Get Your CityCard",
style: TextStyle(color: Colors.white),
Text(
"Get Your CityCards",
style: TextStyle(color: Colors.white,fontSize: 14.sp),
),
SizedBox(width: 10.w),
Image.asset("assets/icons/arrow.png", height: 13.h),

View File

@@ -25,7 +25,7 @@ class GetYourPassCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Get your Pass",
"Get Your Card",
style: GoogleFonts.poppins(
fontSize: 18.sp,
fontWeight: FontWeight.w500,

View File

@@ -52,7 +52,7 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Choose your card",
"Choose Your Card",
style: GoogleFonts.poppins(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
@@ -206,7 +206,7 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
),
),
child: Text(
"Get a Pass",
"Get a Card",
style: GoogleFonts.poppins(
fontWeight: FontWeight.w500,
fontSize: 14.sp,

View File

@@ -176,7 +176,7 @@ class HotelOfferView extends StatelessWidget {
"Choose from a wide variety of Marriott hotels — from elegant urban hideaways and premium city-centre locations to luxurious five-star experiences — all designed to make your trip ",
),
TextSpan(
text: "effortless, comfortable and memorable",
text: "effortless, comfortable",
style: TextStyle(
color: const Color(0xFFF95F62),
fontWeight: FontWeight.w600,

View File

@@ -85,7 +85,7 @@ class ItineraryCreationStartPage extends StatelessWidget {
label: "Lets explore together!",
),
SizedBox(height: 35.h),
SizedBox(height: 10.h),
/// Footer Text
CustomText(

View File

@@ -216,13 +216,20 @@ class _ItineraryCompletionViewState extends State<ItineraryCompletionView>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Lottie.asset(
'assets/intro/itinerary_creating.json',
width: 260.w,
height: 260.w,
fit: BoxFit.contain,
),
SizedBox(height: 24.h),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: GoogleFonts.poppins(fontSize: 24.sp),
children: const [
TextSpan(
text: 'Building\n',
text: 'Creating\n',
style: TextStyle(
color: Color(0xFF364153),
fontWeight: FontWeight.bold,
@@ -238,13 +245,6 @@ class _ItineraryCompletionViewState extends State<ItineraryCompletionView>
],
),
),
SizedBox(height: 24.h),
Lottie.asset(
'assets/intro/itinerary_creating.json',
width: 260.w,
height: 260.w,
fit: BoxFit.contain,
),
],
),
),

View File

@@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/check_in_repository.dart';
part 'check_in_event.dart';
part 'check_in_state.dart';
class CheckInBloc extends Bloc<CheckInEvent, CheckInState> {
final CheckInRepository _checkInRepository;
CheckInBloc({CheckInRepository? checkInRepository})
: _checkInRepository = checkInRepository ?? CheckInRepository(),
super(const CheckInInitial()) {
on<DoCheckInEvent>(_onDoCheckIn);
on<ResetCheckInEvent>(_onReset);
}
Future<void> _onDoCheckIn(
DoCheckInEvent event,
Emitter<CheckInState> emit,
) async {
emit(const CheckInLoading());
try {
final response = await _checkInRepository.checkIn(
passId: event.passId,
attractionId: event.attractionId,
);
emit(CheckInSuccess(data: response.data));
} catch (e) {
emit(CheckInFailure(error: e.toString()));
}
}
void _onReset(
ResetCheckInEvent event,
Emitter<CheckInState> emit,
) {
emit(const CheckInInitial());
}
}

View File

@@ -0,0 +1,27 @@
part of 'check_in_bloc.dart';
abstract class CheckInEvent extends Equatable {
const CheckInEvent();
@override
List<Object?> get props => [];
}
/// Trigger check-in
class DoCheckInEvent extends CheckInEvent {
final int passId;
final int attractionId;
const DoCheckInEvent({
required this.passId,
required this.attractionId,
});
@override
List<Object?> get props => [passId, attractionId];
}
/// Reset state back to initial
class ResetCheckInEvent extends CheckInEvent {
const ResetCheckInEvent();
}

View File

@@ -0,0 +1,38 @@
part of 'check_in_bloc.dart';
abstract class CheckInState extends Equatable {
const CheckInState();
@override
List<Object?> get props => [];
}
/// Initial state
class CheckInInitial extends CheckInState {
const CheckInInitial();
}
/// Loading state
class CheckInLoading extends CheckInState {
const CheckInLoading();
}
/// Success state
class CheckInSuccess extends CheckInState {
final dynamic data;
const CheckInSuccess({required this.data});
@override
List<Object?> get props => [data];
}
/// Failure state
class CheckInFailure extends CheckInState {
final String error;
const CheckInFailure({required this.error});
@override
List<Object?> get props => [error];
}

View File

@@ -1,35 +1,80 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'make_booking_events.dart';
import 'make_booking_state.dart';
import '../repository/make_booking_repository.dart'; // adjust path if needed
class MakeBookingBloc extends Bloc<MakeBookingEvent, MakeBookingState> {
final BookingRepository _repository = BookingRepository();
MakeBookingBloc() : super(const MakeBookingState(loading: true)) {
on<LoadAvailableDates>(_onLoadAvailableDates);
on<SelectDate>(_onSelectDate);
on<ConfirmBooking>(_onConfirmBooking); // NEW
}
void _onLoadAvailableDates(
LoadAvailableDates event, Emitter<MakeBookingState> emit) async {
emit(state.copyWith(loading: true));
// Simulate API load delay
await Future.delayed(const Duration(milliseconds: 500));
// Dummy available dates
final now = DateTime.now();
final available = [
now.add(const Duration(days: 2)),
now.add(const Duration(days: 5)),
now.add(const Duration(days: 7)),
now.add(const Duration(days: 10)),
now.add(const Duration(days: 11)),
now.add(const Duration(days: 13)),
];
// Parse "dd-MM-yyyy" → DateTime
final parts = event.validUpto.split('-');
final validUptoDate = DateTime(
int.parse(parts[2]), // year
int.parse(parts[1]), // month
int.parse(parts[0]), // day
);
emit(state.copyWith(availableDates: available, loading: false));
emit(state.copyWith(
availableDates: [],
validUptoDate: validUptoDate,
loading: false,
));
}
void _onSelectDate(SelectDate event, Emitter<MakeBookingState> emit) {
emit(state.copyWith(startDate: event.startDate, endDate: event.endDate));
}
}
// NEW — calls repository, emits isConfirmed + successMessage on success
Future<void> _onConfirmBooking(
ConfirmBooking event, Emitter<MakeBookingState> emit) async {
emit(state.copyWith(isConfirming: true, error: null));
try {
// Format DateTime → "yyyy-MM-dd" as required by API
final bookingStartDate = _formatDate(event.startDate);
final bookingEndDate = _formatDate(event.endDate);
final response = await _repository.confirmBookingDate(
attractionId: event.attractionId,
bookingId: event.bookingId,
bookingStartDate: bookingStartDate,
bookingEndDate: bookingEndDate,
);
// API response: { "message": "Your booking has been confirmed on 17-03-2026" }
final message = response['message'] as String? ?? 'Booking confirmed!';
emit(state.copyWith(
isConfirming: false,
isConfirmed: true,
successMessage: message,
));
} catch (e) {
emit(state.copyWith(
isConfirming: false,
error: e.toString(),
));
}
}
/// Formats DateTime to "yyyy-MM-dd" e.g. "2026-03-20"
String _formatDate(DateTime date) {
final y = date.year.toString().padLeft(4, '0');
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
}

View File

@@ -5,7 +5,14 @@ abstract class MakeBookingEvent extends Equatable {
List<Object?> get props => [];
}
class LoadAvailableDates extends MakeBookingEvent {}
class LoadAvailableDates extends MakeBookingEvent {
final String validUpto; // format: "dd-MM-yyyy" e.g. "21-03-2026"
LoadAvailableDates({required this.validUpto});
@override
List<Object?> get props => [validUpto];
}
class SelectDate extends MakeBookingEvent {
final DateTime startDate;
@@ -16,3 +23,21 @@ class SelectDate extends MakeBookingEvent {
@override
List<Object?> get props => [startDate, endDate];
}
// NEW — fired when user taps "Confirm Booking"
class ConfirmBooking extends MakeBookingEvent {
final int attractionId;
final int bookingId;
final DateTime startDate;
final DateTime endDate;
ConfirmBooking({
required this.attractionId,
required this.bookingId,
required this.startDate,
required this.endDate,
});
@override
List<Object?> get props => [attractionId, bookingId, startDate, endDate];
}

View File

@@ -5,12 +5,24 @@ class MakeBookingState extends Equatable {
final DateTime? startDate;
final DateTime? endDate;
final bool loading;
final DateTime? validUptoDate;
// NEW fields
final bool isConfirming; // true while API call is in-progress
final bool isConfirmed; // true once API returns success
final String? successMessage; // e.g. "Your booking has been confirmed on 17-03-2026"
final String? error; // non-null when API call fails
const MakeBookingState({
this.availableDates = const [],
this.startDate,
this.endDate,
this.loading = false,
this.validUptoDate,
this.isConfirming = false,
this.isConfirmed = false,
this.successMessage,
this.error,
});
MakeBookingState copyWith({
@@ -18,15 +30,35 @@ class MakeBookingState extends Equatable {
DateTime? startDate,
DateTime? endDate,
bool? loading,
DateTime? validUptoDate,
bool? isConfirming,
bool? isConfirmed,
String? successMessage,
String? error,
}) {
return MakeBookingState(
availableDates: availableDates ?? this.availableDates,
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
loading: loading ?? this.loading,
validUptoDate: validUptoDate ?? this.validUptoDate,
isConfirming: isConfirming ?? this.isConfirming,
isConfirmed: isConfirmed ?? this.isConfirmed,
successMessage: successMessage ?? this.successMessage,
error: error ?? this.error,
);
}
@override
List<Object?> get props => [availableDates, startDate, endDate, loading];
}
List<Object?> get props => [
availableDates,
startDate,
endDate,
loading,
validUptoDate,
isConfirming,
isConfirmed,
successMessage,
error,
];
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../models/pass_attraction_details_model.dart';
import '../../repository/pass_attraction_details_repository.dart';
part 'pass_attraction_details_event.dart';
part 'pass_attraction_details_state.dart';
class PassAttractionDetailsBloc
extends Bloc<PassAttractionDetailsEvent, PassAttractionDetailsState> {
final PassAttractionDetailsRepository _repository;
PassAttractionDetailsBloc({PassAttractionDetailsRepository? repository})
: _repository = repository ?? PassAttractionDetailsRepository(),
super(PassAttractionDetailsInitial()) {
on<FetchPassAttractionDetailsEvent>(_onFetchPassAttractionDetails);
on<ResetPassAttractionDetailsEvent>(_onResetPassAttractionDetails);
}
/// Handle fetching attraction details
Future<void> _onFetchPassAttractionDetails(
FetchPassAttractionDetailsEvent event,
Emitter<PassAttractionDetailsState> emit,
) async {
emit(PassAttractionDetailsLoading());
try {
final PassAttractionDetailsModel attractionDetails =
await _repository.fetchPassAttractionDetails(
attractionId: event.attractionId,
bookingId: event.bookingId,
);
emit(PassAttractionDetailsLoaded(attractionDetails: attractionDetails));
} catch (e) {
emit(PassAttractionDetailsError(
message: e.toString(),
));
}
}
/// Handle resetting state back to initial
void _onResetPassAttractionDetails(
ResetPassAttractionDetailsEvent event,
Emitter<PassAttractionDetailsState> emit,
) {
emit(PassAttractionDetailsInitial());
}
}

View File

@@ -0,0 +1,15 @@
part of 'pass_attraction_details_bloc.dart';
abstract class PassAttractionDetailsEvent {}
class FetchPassAttractionDetailsEvent extends PassAttractionDetailsEvent {
final int attractionId;
final int bookingId;
FetchPassAttractionDetailsEvent({
required this.attractionId,
required this.bookingId,
});
}
class ResetPassAttractionDetailsEvent extends PassAttractionDetailsEvent {}

View File

@@ -0,0 +1,19 @@
part of 'pass_attraction_details_bloc.dart';
abstract class PassAttractionDetailsState {}
class PassAttractionDetailsInitial extends PassAttractionDetailsState {}
class PassAttractionDetailsLoading extends PassAttractionDetailsState {}
class PassAttractionDetailsLoaded extends PassAttractionDetailsState {
final PassAttractionDetailsModel attractionDetails;
PassAttractionDetailsLoaded({required this.attractionDetails});
}
class PassAttractionDetailsError extends PassAttractionDetailsState {
final String message;
PassAttractionDetailsError({required this.message});
}

View File

@@ -0,0 +1,282 @@
class PassAttractionDetailsModel {
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 String createdAt;
final String updatedAt;
final List<AttractionGallery> attractionGalleries;
final List<AttractionInclusion> attractionInclusions;
final List<AttractionFaq> attractionFaqs;
final Qr qr;
PassAttractionDetailsModel({
required this.id,
required this.title,
required this.description,
required this.cityXid,
this.cardTypeXid,
required this.partnerXid,
this.productCode,
required this.subTitle,
required this.urlSlug,
required this.isBookingRequired,
required this.isPartnerAccess,
this.bookingEmail,
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,
required this.qr,
});
factory PassAttractionDetailsModel.fromJson(Map<String, dynamic> json) {
return PassAttractionDetailsModel(
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'],
subTitle: json['subTitle'] ?? "N/A",
urlSlug: json['urlSlug'] ?? "N/A",
isBookingRequired: json['isBookingRequired'] ?? false,
isPartnerAccess: json['isPartnerAccess'] ?? false,
bookingEmail: json['bookingEmail'],
bookingPhoneNumber: json['bookingPhoneNumber'],
address: json['address'] ?? "N/A",
latitudeCoordinate: (json['latitudeCoordinate'] is num)
? (json['latitudeCoordinate'] as num).toDouble()
: 0.0,
longitudeCoordinate: (json['longitudeCoordinate'] is num)
? (json['longitudeCoordinate'] as num).toDouble()
: 0.0,
ticketPriceAdult: (json['ticketPriceAdult'] is num)
? (json['ticketPriceAdult'] as num).toDouble()
: 0.0,
ticketPriceChild: (json['ticketPriceChild'] is num)
? (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'] ?? "N/A",
updatedAt: json['updatedAt'] ?? "N/A",
attractionGalleries: List<AttractionGallery>.from(
(json['attractionGalleries'] ?? [])
.map((e) => AttractionGallery.fromJson(e)),
),
attractionInclusions: List<AttractionInclusion>.from(
(json['attractionInclusions'] ?? [])
.map((e) => AttractionInclusion.fromJson(e)),
),
attractionFaqs: List<AttractionFaq>.from(
(json['attractionFaqs'] ?? [])
.map((e) => AttractionFaq.fromJson(e)),
),
qr: json['qr'] != null ? Qr.fromJson(json['qr']) : Qr.empty(),
);
}
}
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'] ?? "",
altText: json['altText'] ?? "",
isCoverImage: json['isCoverImage'] ?? false,
isActive: json['isActive'] ?? false,
createdAt: json['createdAt'] ?? "",
updatedAt: json['updatedAt'] ?? "",
);
}
}
class AttractionInclusion {
final int id;
final int attractionXid;
final String title;
final String description;
final int? iconXid;
final bool isInclusion;
final bool isActive;
final String createdAt;
final String 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'] ?? "",
description: json['description'] ?? "",
iconXid: json['iconXid'],
isInclusion: json['isInclusion'] ?? false,
isActive: json['isActive'] ?? false,
createdAt: json['createdAt'] ?? "",
updatedAt: json['updatedAt'] ?? "",
);
}
}
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'] ?? "",
faqAnswer: json['faqAnswer'] ?? "",
displayOrder: json['displayOrder'] ?? 0,
isActive: json['isActive'] ?? false,
);
}
}
class Qr {
final String qrCode;
final String qrStatus;
final String qrExpiresAt;
final bool isQrActive;
final String qrNumber;
final String? checkedInDatetime;
final int qrRemainingMinutes;
final String validUpto;
Qr({
required this.qrCode,
required this.qrStatus,
required this.qrExpiresAt,
required this.isQrActive,
required this.qrNumber,
this.checkedInDatetime,
required this.qrRemainingMinutes,
required this.validUpto,
});
factory Qr.fromJson(Map<String, dynamic> json) {
return Qr(
qrCode: json['qrCode'] ?? "N/A",
qrStatus: json['qrStatus'] ?? "N/A",
qrExpiresAt: json['qrExpiresAt'] ?? "N/A",
isQrActive: json['isQrActive'] ?? false,
qrNumber: json['qrNumber'] ?? "N/A",
checkedInDatetime: json['checkedInDatetime'],
qrRemainingMinutes: json['qrRemainingMinutes'] ?? 0,
validUpto: json['validUpto'] ?? "N/A",
);
}
factory Qr.empty() {
return Qr(
qrCode: "N/A",
qrStatus: "N/A",
qrExpiresAt: "N/A",
isQrActive: false,
qrNumber: "N/A",
checkedInDatetime: null,
qrRemainingMinutes: 0,
validUpto: "N/A",
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:dio/dio.dart';
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
class CheckInRepository {
final NetworkApiService _apiService = NetworkApiService();
Future<Response> checkIn({
required int passId,
required int attractionId,
}) async {
final url = '${ApiUrls.checkIn}/$attractionId/$passId';
return await _apiService.postApi(url: url);
}
}

View File

@@ -0,0 +1,27 @@
import '../../networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
class BookingRepository {
final NetworkApiService _apiServices = NetworkApiService();
Future<Map<String, dynamic>> confirmBookingDate({
required int attractionId,
required int bookingId,
required String bookingStartDate,
required String bookingEndDate,
}) async {
try {
final response = await _apiServices.postApi(
url: '${ApiUrls.booking}/$attractionId/$bookingId', // add this key in ApiUrls
data: {
"bookingStartDate": bookingStartDate,
"bookingEndDate": bookingEndDate,
},
);
return response.data as Map<String, dynamic>;
} catch (e) {
throw Exception('Failed to confirm booking date: $e');
}
}
}

View File

@@ -0,0 +1,18 @@
import '../../networkApiServices/network_api_services.dart';
import '../../networkApiServices/api_urls.dart';
import '../models/pass_attraction_details_model.dart';
class PassAttractionDetailsRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Fetch attraction details by attractionId
Future<PassAttractionDetailsModel> fetchPassAttractionDetails({
required int attractionId,
required int bookingId,
}) async {
final response = await _apiService.getApi(
url: '${ApiUrls.passAttractionDetails}/$attractionId/$bookingId',
);
return PassAttractionDetailsModel.fromJson(response.data);
}
}

View File

@@ -10,224 +10,392 @@ import 'package:syncfusion_flutter_datepicker/datepicker.dart';
import '../blocs/make_booking_bloc.dart';
import '../blocs/make_booking_events.dart';
import '../blocs/make_booking_state.dart';
import '../blocs/myPassesAttractionDetails/pass_attraction_details_bloc.dart';
class MakeBookingView extends StatelessWidget {
class MakeBookingView extends StatefulWidget {
final String title;
final String description;
final String validUpto;
final int attractionId;
final int bookingId;
const MakeBookingView({
super.key,
required this.title,
required this.description,
required this.validUpto,
required this.attractionId,
required this.bookingId,
});
@override
State<MakeBookingView> createState() => _MakeBookingViewState();
}
class _MakeBookingViewState extends State<MakeBookingView> {
// true = user tapped Confirm without selecting both dates → show red border + message
bool _showValidationError = false;
// true = user picked start date but hasn't picked end date yet → show orange hint
bool _onlyStartSelected = false;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => MakeBookingBloc()..add(LoadAvailableDates()),
child: BlocBuilder<MakeBookingBloc, MakeBookingState>(
builder: (context, state) {
if (state.loading) {
return const Center(child: CircularProgressIndicator(color: Color(0xffF95F62)));
create: (_) => MakeBookingBloc()
..add(LoadAvailableDates(validUpto: widget.validUpto)),
child: BlocListener<MakeBookingBloc, MakeBookingState>(
listenWhen: (previous, current) =>
previous.isConfirmed != current.isConfirmed ||
previous.error != current.error,
listener: (context, state) {
if (state.isConfirmed && state.successMessage != null) {
Navigator.of(context).pushReplacementNamed(
RouteConstants.bookingSuccessful,
arguments: state.successMessage,
);
// context.read<PassAttractionDetailsBloc>().add(
// FetchPassAttractionDetailsEvent(
// attractionId: widget.attractionId,
// bookingId: widget.bookingId,
// ),
// );
}
if (state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error!),
backgroundColor: Colors.red,
),
);
}
},
final bloc = context.read<MakeBookingBloc>();
final now = DateTime.now();
child: BlocBuilder<MakeBookingBloc, MakeBookingState>(
builder: (context, state) {
if (state.loading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xffF95F62)),
);
}
return SafeArea(
child: Scaffold(
backgroundColor: Colors.white,
body: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
final bloc = context.read<MakeBookingBloc>();
final now = DateTime.now();
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true,),
backWidget(context, "Make Booking", Colors.black),
SizedBox(
height: 20.h,
),
final bool hasStartDate = state.startDate != null;
final bool hasEndDate = state.endDate != null;
final bool bothSelected = hasStartDate && hasEndDate;
// 🏝 Title
Text(
title,
style: GoogleFonts.poppins(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
return SafeArea(
child: Scaffold(
backgroundColor: Colors.white,
body: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: 20.w, vertical: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
),
SizedBox(height: 4.h),
backWidget(context, "Make Booking", Colors.black),
SizedBox(height: 20.h),
// 📄 Description
Text(
description,
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.black54,
Text(
widget.title,
style: GoogleFonts.poppins(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
SizedBox(height: 24.h),
SizedBox(height: 4.h),
// 📅 Calendar Container
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 10.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
Text(
widget.description,
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.black54,
),
),
child: Column(
children: [
Text(
"When are you visiting?",
style: GoogleFonts.poppins(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.black,
SizedBox(height: 24.h),
// ── Calendar Card ──────────────────────────────────────
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
vertical: 12.h, horizontal: 10.w),
decoration: BoxDecoration(
color: Colors.white,
// VALIDATION 1: red border when user tapped Confirm without both dates
border: _showValidationError
? Border.all(
color: Colors.red.shade300, width: 1.2)
: null,
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
),
SizedBox(height: 8.h),
// 🗓 SfDateRangePicker
SfDateRangePicker(
view: DateRangePickerView.month,
selectionMode: DateRangePickerSelectionMode.range,
minDate: now,
maxDate: now.add(const Duration(days: 365)),
enablePastDates: false,
backgroundColor: Colors.white,
showNavigationArrow: true,
// ✅ Put the background color here
headerStyle: DateRangePickerHeaderStyle(
backgroundColor: Colors.white, // <-- removes the purple strip
textAlign: TextAlign.center,
textStyle: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
],
),
monthViewSettings: DateRangePickerMonthViewSettings(
firstDayOfWeek: 7,
viewHeaderStyle: DateRangePickerViewHeaderStyle(
textStyle: GoogleFonts.poppins(
color: Colors.grey.shade600,
fontSize: 11.sp,
fontWeight: FontWeight.w500,
),
),
blackoutDates: _getUnavailableDates(state.availableDates, now),
),
monthCellStyle: DateRangePickerMonthCellStyle(
textStyle: GoogleFonts.poppins(fontSize: 12.sp, color: Colors.black87),
todayTextStyle: GoogleFonts.poppins(
fontSize: 12.sp, color: Colors.black, fontWeight: FontWeight.w500),
blackoutDateTextStyle: GoogleFonts.poppins(
fontSize: 12.sp, color: Colors.grey.shade400,
decoration: TextDecoration.lineThrough),
),
rangeTextStyle: GoogleFonts.poppins(
fontSize: 12.sp, color: Colors.white, fontWeight: FontWeight.w500),
startRangeSelectionColor: const Color(0xffFF5A5F),
endRangeSelectionColor: const Color(0xffFF5A5F),
rangeSelectionColor: const Color(0xffFF5A5F).withOpacity(0.15),
selectionTextStyle: GoogleFonts.poppins(
fontSize: 12.sp, color: Colors.white, fontWeight: FontWeight.w500),
initialSelectedRange: state.startDate != null && state.endDate != null
? PickerDateRange(state.startDate, state.endDate)
: null,
onSelectionChanged: (DateRangePickerSelectionChangedArgs args) {
if (args.value is PickerDateRange) {
final start = args.value.startDate;
final end = args.value.endDate;
if (start != null && end != null) {
bloc.add(SelectDate(start, end));
}
}
},
),
],
),
),
SizedBox(height: 40.h),
// ✅ Confirm button
GestureDetector(
onTap: () {
if (state.startDate != null && state.endDate != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Booking confirmed from "
"${state.startDate!.toLocal().toString().split(' ')[0]} "
"to ${state.endDate!.toLocal().toString().split(' ')[0]}",
child: Column(
children: [
Text(
"When are you visiting?",
style: GoogleFonts.poppins(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
);
Navigator.of(context).pushNamed(RouteConstants.bookingSuccessful);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Please select a valid date range"),
SizedBox(height: 8.h),
SfDateRangePicker(
view: DateRangePickerView.month,
selectionMode:
DateRangePickerSelectionMode.range,
minDate: now.add(const Duration(days: 1)),
maxDate: state.validUptoDate ??
now.add(const Duration(days: 365)),
enablePastDates: false,
backgroundColor: Colors.white,
showNavigationArrow: true,
headerStyle: DateRangePickerHeaderStyle(
backgroundColor: Colors.white,
textAlign: TextAlign.center,
textStyle: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
monthViewSettings:
DateRangePickerMonthViewSettings(
firstDayOfWeek: 7,
viewHeaderStyle:
DateRangePickerViewHeaderStyle(
textStyle: GoogleFonts.poppins(
color: Colors.grey.shade600,
fontSize: 11.sp,
fontWeight: FontWeight.w500,
),
),
blackoutDates: const [],
),
monthCellStyle: DateRangePickerMonthCellStyle(
textStyle: GoogleFonts.poppins(
fontSize: 12.sp, color: Colors.black87),
todayTextStyle: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.black,
fontWeight: FontWeight.w500),
blackoutDateTextStyle: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.grey.shade400,
decoration: TextDecoration.lineThrough),
),
rangeTextStyle: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.white,
fontWeight: FontWeight.w500),
startRangeSelectionColor:
const Color(0xffFF5A5F),
endRangeSelectionColor:
const Color(0xffFF5A5F),
rangeSelectionColor:
const Color(0xffFF5A5F).withOpacity(0.15),
selectionTextStyle: GoogleFonts.poppins(
fontSize: 12.sp,
color: Colors.white,
fontWeight: FontWeight.w500),
initialSelectedRange: bothSelected
? PickerDateRange(
state.startDate, state.endDate)
: null,
// VALIDATION 2: detect partial vs full selection
onSelectionChanged:
(DateRangePickerSelectionChangedArgs args) {
if (args.value is PickerDateRange) {
final start = args.value.startDate;
final end = args.value.endDate;
if (start != null && end != null) {
// ✅ Both selected — clear all error states
setState(() {
_onlyStartSelected = false;
_showValidationError = false;
});
bloc.add(SelectDate(start, end));
} else if (start != null && end == null) {
// ⚠️ Only start tapped — guide user to pick end
setState(() {
_onlyStartSelected = true;
_showValidationError = false;
});
} else {
// Selection cleared
setState(() {
_onlyStartSelected = false;
});
}
}
},
),
);
}
},
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 14.h),
decoration: BoxDecoration(
color: const Color(0xffFF5A5F),
borderRadius: BorderRadius.circular(30.r),
// VALIDATION 3: inline hint message below calendar
if (_onlyStartSelected || _showValidationError)
Padding(
padding: EdgeInsets.only(
top: 8.h, left: 4.w, right: 4.w),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
size: 14.sp,
// orange for guidance, red for submit error
color: _showValidationError
? Colors.red
: Colors.orange.shade700,
),
SizedBox(width: 6.w),
Expanded(
child: Text(
_showValidationError && !hasStartDate
? "Please select a check-in date to continue"
: _showValidationError &&
hasStartDate &&
!hasEndDate
? "Please also select a check-out date"
: "Now tap an end date to complete your range",
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: _showValidationError
? Colors.red
: Colors.orange.shade700,
),
),
),
],
),
),
],
),
child: Center(
child: Text(
"Confirm Booking",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
// VALIDATION 4: selected range summary chip (only when both selected)
if (bothSelected) ...[
SizedBox(height: 12.h),
Center(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 14.w, vertical: 8.h),
decoration: BoxDecoration(
color:
const Color(0xffFF5A5F).withOpacity(0.08),
borderRadius: BorderRadius.circular(10.r),
border: Border.all(
color: const Color(0xffFF5A5F)
.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline_rounded,
size: 15.sp,
color: const Color(0xffFF5A5F)),
SizedBox(width: 6.w),
Text(
"${_fmt(state.startDate!)}${_fmt(state.endDate!)}",
style: GoogleFonts.poppins(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: const Color(0xffFF5A5F),
),
),
],
),
),
), // close Center
],
SizedBox(height: 40.h),
// ── Confirm Button ─────────────────────────────────────
GestureDetector(
onTap: state.isConfirming
? null
: () {
// VALIDATION 5: check both dates before firing API
if (!hasStartDate || !hasEndDate) {
setState(() {
_showValidationError = true;
_onlyStartSelected = false;
});
return; // stop here — don't call API
}
// ✅ Both dates present — dispatch to bloc
bloc.add(ConfirmBooking(
attractionId: widget.attractionId,
bookingId: widget.bookingId,
startDate: state.startDate!,
endDate: state.endDate!,
));
},
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 14.h),
decoration: BoxDecoration(
color: state.isConfirming
? const Color(0xffFF5A5F).withOpacity(0.6)
: const Color(0xffFF5A5F),
borderRadius: BorderRadius.circular(30.r),
),
child: Center(
child: state.isConfirming
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
"Confirm Booking",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
],
],
),
),
),
),
);
},
);
},
),
),
);
}
/// Marks unavailable days (those not in availableDates) as blackout
List<DateTime> _getUnavailableDates(List<DateTime> available, DateTime start) {
final end = start.add(const Duration(days: 365));
final allDays = List.generate(
end.difference(start).inDays,
(i) => DateTime(start.year, start.month, start.day + i),
);
return allDays
.where((day) => !available.any((a) =>
a.year == day.year && a.month == day.month && a.day == day.day))
.toList();
/// "20 Mar 2026"
String _fmt(DateTime d) {
const months = [
'Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec'
];
return '${d.day} ${months[d.month - 1]} ${d.year}';
}
}
}

View File

@@ -6,7 +6,10 @@ import 'package:google_fonts/google_fonts.dart';
import '../../common_packages/back_widget.dart';
class BookingSuccessfulPageView extends StatelessWidget {
const BookingSuccessfulPageView({super.key});
final String message;
const BookingSuccessfulPageView({super.key, required this.message});
@override
Widget build(BuildContext context) {
@@ -39,7 +42,7 @@ class BookingSuccessfulPageView extends StatelessWidget {
SizedBox(height: 20.h),
Text(
"Your booking has been Confirmed on 08/01/2025",
message,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16.sp,

View File

@@ -1,3 +1,5 @@
import 'dart:async';
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';
@@ -7,30 +9,92 @@ 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 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import '../../attraction_details/bloc/attraction_details_bloc.dart';
import '../../attraction_details/bloc/attraction_details_event.dart';
import '../../attraction_details/bloc/attraction_details_state.dart';
import '../../attraction_details/repository/attraction_details_repository.dart';
import '../../core/route_constants.dart';
import '../blocs/myPassesAttractionDetails/pass_attraction_details_bloc.dart';
import '../repository/pass_attraction_details_repository.dart';
import '../widgets/check_in_bottom_sheet.dart';
import '../widgets/how_to_redeem_bottomsheet.dart';
class PassAttractionDetailsView extends StatelessWidget {
final int? attractionId;
class PassAttractionDetailsView extends StatefulWidget {
final int attractionId;
final int bookingId;
const PassAttractionDetailsView({
super.key,
required this.attractionId,
required this.bookingId,
});
@override
State<PassAttractionDetailsView> createState() =>
_PassAttractionDetailsViewState();
}
class _PassAttractionDetailsViewState extends State<PassAttractionDetailsView> {
bool _isCheckedIn = false;
int _remainingSeconds = 0;
Timer? _countdownTimer;
@override
void dispose() {
_countdownTimer?.cancel();
super.dispose();
}
void _startCountdown(int minutes) {
_countdownTimer?.cancel();
setState(() {
_isCheckedIn = true;
_remainingSeconds = minutes * 60;
});
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_remainingSeconds > 0) {
_remainingSeconds--;
} else {
timer.cancel();
if (!mounted) return;
setState(() {
_isCheckedIn = false;
});
context.read<PassAttractionDetailsBloc>().add(
FetchPassAttractionDetailsEvent(
attractionId: widget.attractionId,
bookingId: widget.bookingId,
),
);
}
});
});
}
String get _timerLabel {
final m = _remainingSeconds ~/ 60;
final s = _remainingSeconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AttractionDetailsBloc(
repository: AttractionDetailsRepository(),
)..add(FetchAttractionDetails(attractionId: attractionId??0)),
child: BlocBuilder<AttractionDetailsBloc, AttractionDetailsState>(
create: (_) =>
PassAttractionDetailsBloc(
repository: PassAttractionDetailsRepository(),
)..add(
FetchPassAttractionDetailsEvent(
attractionId: widget.attractionId ?? 0,
bookingId: widget.bookingId ?? 0,
),
),
child: BlocBuilder<PassAttractionDetailsBloc, PassAttractionDetailsState>(
builder: (context, state) {
if (state is AttractionDetailsLoading) {
if (state is PassAttractionDetailsLoading) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
@@ -39,25 +103,22 @@ class PassAttractionDetailsView extends StatelessWidget {
);
}
if (state is AttractionDetailsError) {
if (state is PassAttractionDetailsError) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text(
state.message,
style: TextStyle(color: Colors.red),
),
child: Text(state.message, style: TextStyle(color: Colors.red)),
),
);
}
if (state is AttractionDetailsLoaded) {
if (state is PassAttractionDetailsLoaded) {
final attraction = state.attractionDetails;
final coverImage = attraction.attractionGalleries
.firstWhere(
(gallery) => gallery.isCoverImage,
orElse: () => attraction.attractionGalleries.first,
)
orElse: () => attraction.attractionGalleries.first,
)
.filePathUrl;
return Scaffold(
@@ -90,7 +151,9 @@ class PassAttractionDetailsView extends StatelessWidget {
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 20.w, vertical: 10.h),
horizontal: 20.w,
vertical: 10.h,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -133,7 +196,8 @@ class PassAttractionDetailsView extends StatelessWidget {
Positioned(
bottom: 31.h,
left: 12.w,
right: 60.w, // Add this - leaves space for share button
right: 60
.w, // Add this - leaves space for share button
child: Text(
attraction.title,
style: TextStyle(
@@ -177,7 +241,10 @@ class PassAttractionDetailsView extends StatelessWidget {
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 24.h),
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 24.h,
),
child: Container(
width: double.infinity,
padding: EdgeInsets.all(20.w),
@@ -191,14 +258,48 @@ class PassAttractionDetailsView extends StatelessWidget {
),
child: Column(
children: [
if (_isCheckedIn)
Container(
margin: EdgeInsets.only(bottom: 12.h),
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 6.h,
),
decoration: BoxDecoration(
color: const Color(
0xFFF95F62,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(20.r),
border: Border.all(
color: const Color(0xFFF95F62),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.access_time_rounded,
color: const Color(0xFFF95F62),
size: 16.sp,
),
SizedBox(width: 6.w),
Text(
_timerLabel,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFFF95F62),
),
),
],
),
),
Text(
"Scan this at the site of the attraction",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: Color(0xFFF95F62),
),
textAlign: TextAlign.center,
),
SizedBox(height: 20.h),
// QR Code Image
@@ -208,11 +309,11 @@ class PassAttractionDetailsView extends StatelessWidget {
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
),
child: Image.asset(
'assets/images/qr_image.png',
height: 200.h,
width: 200.w,
fit: BoxFit.contain,
child: QrImageView(
data:
"Details:\nQR No. : ${attraction.qr.qrNumber}\nQR Code : ${attraction.qr.qrCode}\nStatus : ${attraction.qr.qrStatus}\nExpires At: ${attraction.qr.qrExpiresAt}\nChecked In: ${attraction.qr.checkedInDatetime}\nRemaining : ${attraction.qr.qrRemainingMinutes} mins\nIs Active : ${attraction.qr.isQrActive ? "Yes" : "No"}",
version: QrVersions.auto,
size: 200.w,
),
),
SizedBox(height: 16.h),
@@ -221,7 +322,7 @@ class PassAttractionDetailsView extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"IYFHHVN254ADSD",
attraction.qr.qrNumber,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
@@ -232,10 +333,18 @@ class PassAttractionDetailsView extends StatelessWidget {
SizedBox(width: 8.w),
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: "IYFHHVN254ADSD"));
ScaffoldMessenger.of(context).showSnackBar(
Clipboard.setData(
ClipboardData(
text: attraction.qr.qrNumber,
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text('Code copied to clipboard'),
content: Text(
'Code copied to clipboard',
),
duration: Duration(seconds: 2),
backgroundColor: Color(0xFFF95F62),
),
@@ -251,27 +360,92 @@ class PassAttractionDetailsView extends StatelessWidget {
),
SizedBox(height: 20.h),
// Check in Button
// AFTER
SizedBox(
width: double.infinity,
height: 50.h,
child: ElevatedButton(
onPressed: () {
// Add your check-in logic here
},
onPressed: _isCheckedIn
? null // ← not tappable after check-in
: () async {
final result =
await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(
top: Radius.circular(
20.r,
),
),
),
builder: (_) =>
CheckInBottomSheet(
attractionName:
attraction.title,
minuteTime: attraction
.qr
.qrRemainingMinutes,
bookingId:
widget.bookingId,
attractionId:
widget.attractionId,
),
);
if (result == true) {
context
.read<
PassAttractionDetailsBloc
>()
.add(
FetchPassAttractionDetailsEvent(
attractionId:
widget.attractionId,
bookingId: widget.bookingId,
),
);
_startCountdown(
attraction.qr.qrRemainingMinutes,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFFF95F62),
backgroundColor: _isCheckedIn
? Colors
.grey
.shade400 // ← greyed out
: const Color(0xFFF95F62),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.r),
),
elevation: 0,
disabledBackgroundColor:
Colors.grey.shade400,
),
child: Text(
"Check in",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isCheckedIn) ...[
Icon(
Icons.check_circle_outline,
color: Colors.white,
size: 18.sp,
),
SizedBox(width: 8.w),
],
Text(
_isCheckedIn
? "Checked In"
: "Check in",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
@@ -289,7 +463,18 @@ class PassAttractionDetailsView extends StatelessWidget {
),
GestureDetector(
onTap: () {
// Add your help/support navigation here
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20.r),
),
),
builder: (_) => HowToRedeemBottomSheet(
attractionName: attraction.title,
),
);
},
child: Text(
"Click Here",
@@ -310,8 +495,7 @@ class PassAttractionDetailsView extends StatelessWidget {
// About Section
Padding(
padding:
EdgeInsets.only(left: 16.w, right: 16.w,),
padding: EdgeInsets.only(left: 16.w, right: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -371,7 +555,7 @@ class PassAttractionDetailsView extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [
CustomText(
text: "Contact Number",
@@ -381,7 +565,9 @@ class PassAttractionDetailsView extends StatelessWidget {
),
SizedBox(height: 6.h),
CustomText(
text: attraction.bookingPhoneNumber??"N/A",
text:
attraction.bookingPhoneNumber ??
"N/A",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
@@ -420,7 +606,7 @@ class PassAttractionDetailsView extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [
CustomText(
text: "Email",
@@ -430,7 +616,8 @@ class PassAttractionDetailsView extends StatelessWidget {
),
SizedBox(height: 6.h),
CustomText(
text: attraction.bookingEmail??"N/A",
text:
attraction.bookingEmail ?? "N/A",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
@@ -451,8 +638,16 @@ class PassAttractionDetailsView extends StatelessWidget {
SizedBox(height: 16.h),
InkWell(
onTap: () {
Navigator.of(context)
.pushNamed(RouteConstants.makeBooking);
Navigator.of(context).pushNamed(
RouteConstants.makeBooking,
arguments: {
"title": attraction.title,
"description": attraction.description,
"validUpto": attraction.qr.validUpto,
"attractionId": attraction.id,
"bookingId": widget.bookingId,
},
);
},
child: Container(
padding: EdgeInsets.symmetric(
@@ -465,12 +660,12 @@ class PassAttractionDetailsView extends StatelessWidget {
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [
CustomText(
text: "Via CityCards",
@@ -516,11 +711,11 @@ class PassAttractionDetailsView extends StatelessWidget {
.where((inclusion) => inclusion.isInclusion)
.map(
(inclusion) => includedBox(
"assets/icons/bus.png",
inclusion.title,
inclusion.description,
),
)
"assets/icons/bus.png",
inclusion.title,
inclusion.description,
),
)
.toList(),
),
SizedBox(height: 30.h),
@@ -559,13 +754,17 @@ class PassAttractionDetailsView extends StatelessWidget {
),
initialZoom: 15.0,
interactionOptions: InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
flags:
InteractiveFlag.all &
~InteractiveFlag.rotate,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.example.citycards_customer',
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.example.citycards_customer',
),
MarkerLayer(
markers: [
@@ -616,7 +815,6 @@ class PassAttractionDetailsView extends StatelessWidget {
);
}).toList(),
),
],
),
),
@@ -630,9 +828,7 @@ class PassAttractionDetailsView extends StatelessWidget {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Text("Something went wrong"),
),
body: Center(child: Text("Something went wrong")),
);
},
),
@@ -680,10 +876,7 @@ class PassAttractionDetailsView extends StatelessWidget {
);
}
Widget faqBox({
required String title,
required String desc,
}) {
Widget faqBox({required String title, required String desc}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
@@ -713,13 +906,9 @@ class PassAttractionDetailsView extends StatelessWidget {
],
),
SizedBox(height: 9.h),
CustomText(
text: desc,
size: 11.sp,
color: const Color(0xFF7D7D7D),
),
CustomText(text: desc, size: 11.sp, color: const Color(0xFF7D7D7D)),
],
),
);
}
}
}

View File

@@ -14,11 +14,13 @@ import '../repository/my_passes_attractions_repository.dart';
class PassAttractionsPage extends StatelessWidget {
final int cityXid;
final int bookingId;
final String source;
const PassAttractionsPage({
super.key,
required this.cityXid,
required this.bookingId,
required this.source,
});
@@ -156,7 +158,7 @@ class PassAttractionsPage extends StatelessWidget {
children: state.filteredAttractions
.map(
(attraction) => PassAttractionCard(
attraction: attraction,
attraction: attraction, bookingId: bookingId,
),
)
.toList(),

View File

@@ -229,7 +229,10 @@ class _PassDetailsViewState extends State<PassDetailsView> {
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.passAttractionDetails,
arguments: attraction.id,
arguments: {
'attractionId': attraction.id,
'bookingId': widget.bookingId, // pass your actual bookingId here
},
);
},
child: _attractionCard(
@@ -261,7 +264,7 @@ class _PassDetailsViewState extends State<PassDetailsView> {
Navigator.pushNamed(
context,
RouteConstants.passAttractionsPage,
arguments: {'cityId': city?.id, 'source': 'my_passes'},
arguments: {'cityId': city?.id, 'source': 'my_passes', 'bookingId': widget.bookingId},
);
}),

View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../common_packages/custom_filled_button.dart';
import '../blocs/checkIn/check_in_bloc.dart';
import '../repository/check_in_repository.dart';
class CheckInBottomSheet extends StatelessWidget {
final String attractionName;
final int minuteTime;
final int bookingId;
final int attractionId;
const CheckInBottomSheet({
super.key,
required this.attractionName,
required this.minuteTime,
required this.bookingId,
required this.attractionId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CheckInBloc(checkInRepository: CheckInRepository()),
child: BlocConsumer<CheckInBloc, CheckInState>(
listener: (context, state) {
if (state is CheckInSuccess) {
Navigator.pop(context, true); // close sheet
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Checked In Successful"),
backgroundColor: const Color(0xFF22C55E),
behavior: SnackBarBehavior.floating,
),
);
} else if (state is CheckInFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: const Color(0xFFF95F62),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
final isLoading = state is CheckInLoading;
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 + 24.h,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
/// --- Drag Handle ---
Container(
height: 4.h,
width: 40.w,
decoration: BoxDecoration(
color: const Color(0xFF2D3134),
borderRadius: BorderRadius.circular(4.r),
),
),
SizedBox(height: 20.h),
/// --- Title ---
CustomText(
text: "Ready to check in?",
size: 22.sp,
weight: FontWeight.w700,
),
SizedBox(height: 16.h),
/// --- Subtitle with attraction name highlighted ---
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: GoogleFonts.poppins(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: const Color(0xFF6B7280),
height: 1.5,
),
children: [
const TextSpan(
text: "Only activate when you are at the entrance of ",
),
TextSpan(
text: "$attractionName.",
style: TextStyle(
color: const Color(0xFFF95F62),
fontWeight: FontWeight.w700,
fontSize: 15.sp,
),
),
],
),
),
SizedBox(height: 20.h),
/// --- Timer Info Card ---
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 14.h,
),
decoration: BoxDecoration(
color: const Color(0xFFF95F62).withOpacity(0.08),
borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: const Color(0xFFF95F62)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 36.h,
width: 36.w,
decoration: BoxDecoration(
color: const Color(0xFFF95F62).withOpacity(0.12),
shape: BoxShape.circle,
),
child: Center(
child: Icon(
Icons.access_time_rounded,
color: const Color(0xFFF95F62),
size: 20.sp,
),
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "$minuteTime minute timer",
size: 15.sp,
weight: FontWeight.w700,
color: const Color(0xFFF95F62),
),
SizedBox(height: 4.h),
CustomText(
text:
"Once activated, the pass is valid for $minuteTime minutes. This action cannot be undone",
size: 11.sp,
weight: FontWeight.w400,
color: const Color(0xFFF95F62).withOpacity(0.8),
maxLines: 3,
),
],
),
),
],
),
),
SizedBox(height: 24.h),
/// --- Activate Button ---
isLoading
? SizedBox(
height: 52.h,
child: const Center(
child: CircularProgressIndicator(
color: Color(0xFFF95F62),
),
),
)
: CustomFilledButton(
label: "Activate Pass Now",
width: double.infinity,
height: 52.h,
showArrow: true,
onTap: () {
context.read<CheckInBloc>().add(
DoCheckInEvent(
passId: bookingId,
attractionId: attractionId,
),
);
},
),
SizedBox(height: 16.h),
/// --- Dismiss Text Button ---
GestureDetector(
onTap: isLoading ? null : () => Navigator.pop(context),
child: CustomText(
text: "I'm not at the entrance yet",
size: 14.sp,
weight: FontWeight.w500,
color: isLoading
? const Color(0xFFF95F62).withOpacity(0.4)
: const Color(0xFFF95F62),
),
),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../common_packages/custom_filled_button.dart';
import '../../core/route_constants.dart';
class HowToRedeemBottomSheet extends StatelessWidget {
final String attractionName;
const HowToRedeemBottomSheet({
super.key,
required this.attractionName,
});
@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 + 24.h,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
/// --- Drag Handle ---
Container(
height: 4.h,
width: 40.w,
decoration: BoxDecoration(
color: const Color(0xFF2D3134),
borderRadius: BorderRadius.circular(4.r),
),
),
SizedBox(height: 20.h),
/// --- Title ---
CustomText(
text: "How to redeem my attraction pass?",
size: 20.sp,
weight: FontWeight.w700,
textAlign: TextAlign.center,
),
SizedBox(height: 20.h),
/// --- Body with attraction name highlighted ---
RichText(
textAlign: TextAlign.start,
text: TextSpan(
style: GoogleFonts.poppins(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: const Color(0xFF3D3D3D),
height: 1.6,
),
children: [
const TextSpan(
text:
"To redeem your attraction pass, present the QR code at the entrance. Our staff will scan it, granting you access to the wonders within. Enjoy your adventure at ",
),
TextSpan(
text: "$attractionName!",
style: GoogleFonts.poppins(
color: const Color(0xFFF95F62),
fontWeight: FontWeight.w700,
fontSize: 14.sp,
),
),
],
),
),
SizedBox(height: 24.h),
/// --- Trouble text ---
CustomText(
text: "Having trouble redeeming the pass?",
size: 14.sp,
weight: FontWeight.w400,
color: Colors.black54,
textAlign: TextAlign.center,
),
SizedBox(height: 16.h),
/// --- Contact Support Button ---
CustomFilledButton(
label: "Contact Support",
width: double.infinity,
height: 52.h,
onTap: () {
Navigator.pushNamed(context, RouteConstants.contactUs);
},
),
],
),
);
}
}

View File

@@ -8,7 +8,8 @@ import '../../core/route_constants.dart';
class PassAttractionCard extends StatelessWidget {
final Attraction attraction;
const PassAttractionCard({super.key, required this.attraction});
final int bookingId;
const PassAttractionCard({super.key, required this.attraction, required this.bookingId});
@override
Widget build(BuildContext context) {
@@ -36,7 +37,10 @@ class PassAttractionCard extends StatelessWidget {
onTap: () {
Navigator.of(context).pushNamed(
RouteConstants.passAttractionDetails,
arguments: attraction.id,
arguments: {
'attractionId': attraction.id,
'bookingId': bookingId, // pass your actual bookingId here
},
);
},
child: Container(

View File

@@ -12,6 +12,7 @@ class ApiUrls {
static const attractionsList = "$baseUrl/mobile/list/all";
static const passAttractionsList = "$baseUrl/mobile/passes/mobile/list";
static const attractionDetails = "$baseUrl/mobile/list";
static const passAttractionDetails = "$baseUrl/mobile/passes/attractionDetail";
static const home = "$baseUrl/mobile";
static const faqPrivacyTerms = "$baseUrl/mobile/user/cms-data";
static const userProfile = "$baseUrl/mobile/user";
@@ -37,5 +38,7 @@ class ApiUrls {
static const submitTicket = "$baseUrl/mobile/user/support";
static const createPostCard = "$baseUrl/mobile/postcards";
static const addToCartPasses = "$baseUrl/mobile/passes/add-to-cart";
static const checkIn = "$baseUrl/mobile/passes/start-checkin";
static const booking = "$baseUrl/mobile/passes/booking-date-confirm";
static const createItinerary = "$baseUrl/mobile/itinerary";
}

View File

@@ -19,8 +19,8 @@ class NetworkApiService {
NetworkApiService._internal() {
_dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
connectTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',

View File

@@ -239,6 +239,7 @@ class PostcardCreationBloc
userProfileFullName: event.fullName,
userProfileEmail: event.email,
userProfilePhone: event.phone,
isdCode: event.isdCode,
userProfileAddress: event.address,
userProfileCity: event.city,
userProfileState: event.state,

View File

@@ -82,6 +82,7 @@ class StoreUserProfileData extends PostcardCreationEvent {
final String? fullName;
final String? email;
final String? phone;
final String? isdCode;
final String? address;
final String? city;
final String? state;
@@ -92,6 +93,7 @@ class StoreUserProfileData extends PostcardCreationEvent {
this.fullName,
this.email,
this.phone,
this.isdCode,
this.address,
this.city,
this.state,

View File

@@ -15,6 +15,7 @@ class PostcardCreationState {
final String? fullName;
final String? emailId;
final String? phoneNumber;
final String? isdCode;
final String address;
final String? city;
final String? country;
@@ -51,6 +52,7 @@ class PostcardCreationState {
this.fullName,
this.emailId,
this.phoneNumber,
this.isdCode,
this.city,
this.country,
this.state,
@@ -86,6 +88,7 @@ class PostcardCreationState {
String? fullName,
String? emailId,
String? phoneNumber,
String? isdCode,
String? address,
String? city,
String? country,
@@ -120,6 +123,7 @@ class PostcardCreationState {
fullName: fullName ?? this.fullName,
emailId: emailId ?? this.emailId,
phoneNumber: phoneNumber ?? this.phoneNumber,
isdCode: isdCode ?? this.isdCode,
address: address ?? this.address,
city: city ?? this.city,
country: country ?? this.country,

View File

@@ -62,8 +62,9 @@ class PostcardCreationPage extends StatelessWidget {
initialSenderFullName: state.isGift ? state.userProfileFullName : null, // ⬅️ ADD
initialSenderCity: state.isGift ? state.userProfileCity : null, // ⬅️ ADD
initialSenderCountry: state.isGift ? state.userProfileCountry : null,
initialSenderEmail: state.isGift ? state.userProfileEmail : null,
initialSenderPhone: state.isGift ? state.userProfilePhone : null,
initialSenderEmail: state.userProfileEmail,
initialSenderPhone: state.userProfilePhone,
initialSenderisdCode: state.isdCode,
);
break;
case PostcardStep.checkout:

View File

@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:geocoding/geocoding.dart';
import '../../common_packages/app_bar.dart';
import '../blocs/addToCartPostcard/add_to_cart_postcard_bloc.dart';
import '../blocs/addToCartPostcard/add_to_cart_postcard_event.dart';
@@ -20,10 +21,11 @@ class PostcardPurchaseFormPageView extends StatefulWidget {
final String? initialState;
final String? initialZipCode;
final String? initialCountry;
final String? initialSenderFullName; // ⬅️ ADD
final String? initialSenderCity; // ⬅️ ADD
final String? initialSenderFullName;
final String? initialSenderCity;
final String? initialSenderCountry;
final String? initialSenderEmail;
final String? initialSenderisdCode;
final String? initialSenderPhone;
const PostcardPurchaseFormPageView({
@@ -31,6 +33,7 @@ class PostcardPurchaseFormPageView extends StatefulWidget {
this.initialFullName,
this.initialSenderEmail,
this.initialSenderPhone,
this.initialSenderisdCode,
this.initialAddress,
this.initialCity,
this.initialState,
@@ -42,41 +45,46 @@ class PostcardPurchaseFormPageView extends StatefulWidget {
});
@override
State<PostcardPurchaseFormPageView> createState() => _PostcardPurchaseFormPageViewState();
State<PostcardPurchaseFormPageView> createState() =>
_PostcardPurchaseFormPageViewState();
}
class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageView> {
class _PostcardPurchaseFormPageViewState
extends State<PostcardPurchaseFormPageView> {
final _formKey = GlobalKey<FormState>();
// Sender controllers
final _senderFullNameController = TextEditingController();
final _senderCityController = TextEditingController();
final _senderEmailController = TextEditingController();
final _senderPhoneController = TextEditingController();
String? _senderSelectedCountry;
// Controllers
final _senderCountryController = TextEditingController(); // ← was dropdown
// Recipient controllers
final _titleController = TextEditingController();
final _recipientFullNameController = TextEditingController();
final _recipientAddressController = TextEditingController();
final _recipientCityController = TextEditingController();
final _recipientZipCodeController = TextEditingController();
String? _recipientSelectedCountry;
String? _recipientSelectedState;
final _recipientStateController = TextEditingController(); // ← was dropdown
final _recipientCountryController = TextEditingController(); // ← was dropdown
// Zip auto-fill loading flag
bool _isZipLoading = false;
@override
void initState() {
super.initState();
// Initialize controllers with prefill values
_recipientFullNameController.text = widget.initialFullName ?? '';
_recipientAddressController.text = widget.initialAddress ?? '';
_recipientCityController.text = widget.initialCity ?? '';
_recipientZipCodeController.text = widget.initialZipCode ?? '';
_recipientSelectedState = widget.initialState;
_recipientSelectedCountry = widget.initialCountry;
_recipientStateController.text = widget.initialState ?? '';
_recipientCountryController.text = widget.initialCountry ?? '';
_senderFullNameController.text = widget.initialSenderFullName ?? '';
_senderCityController.text = widget.initialSenderCity ?? '';
_senderSelectedCountry = widget.initialSenderCountry;
_senderCountryController.text = widget.initialSenderCountry ?? '';
_senderEmailController.text = widget.initialSenderEmail ?? '';
_senderPhoneController.text = widget.initialSenderPhone ?? '';
}
@@ -90,9 +98,40 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
_recipientAddressController.dispose();
_recipientCityController.dispose();
_recipientZipCodeController.dispose();
_recipientStateController.dispose();
_recipientCountryController.dispose();
_senderCountryController.dispose();
_senderFullNameController.dispose();
_senderCityController.dispose();
super.dispose();
}
// ── Zip → City, State, Country auto-fill ──────────────────────────────────
Future<void> _fetchLocationFromZip(String zip) async {
if (zip.trim().length < 4) return;
setState(() => _isZipLoading = true);
try {
final locations = await locationFromAddress(zip);
if (locations.isNotEmpty) {
final placemarks = await placemarkFromCoordinates(
locations.first.latitude,
locations.first.longitude,
);
final place = placemarks.first;
setState(() {
_recipientCityController.text = place.locality ?? '';
_recipientStateController.text = place.administrativeArea ?? '';
_recipientCountryController.text = place.country ?? '';
});
}
} catch (e) {
debugPrint("Zip lookup failed: $e");
} finally {
if (mounted) setState(() => _isZipLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return BlocBuilder<PostcardCreationBloc, PostcardCreationState>(
@@ -102,13 +141,9 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
return BlocListener<AddToCartPostCardBloc, AddToCartPostCardState>(
listener: (context, cartState) {
if (cartState is AddToCartPostCardSuccess) {
// Update the postcard number in creation bloc
creationBloc.add(UpdatePostcardNumber(cartState.pcNumber));
// Navigate to next step (checkout)
creationBloc.add(GoToNextStep());
} else if (cartState is AddToCartPostCardFailure) {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(cartState.message),
@@ -132,13 +167,15 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
),
GestureDetector(
onTap: () {
context.read<PostcardCreationBloc>().add(GoToPreviousStep());
context
.read<PostcardCreationBloc>()
.add(GoToPreviousStep());
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
Icon(Icons.arrow_back, size: 20),
const Icon(Icons.arrow_back, size: 20),
const SizedBox(width: 8),
Text(
"Back",
@@ -151,6 +188,8 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
),
),
),
// ── Postcard image + title ─────────────────────────────
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -174,8 +213,6 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
),
),
const SizedBox(width: 16),
/// 👇 Title input with heading on top
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -234,72 +271,60 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
),
],
),
const SizedBox(height: 28),
if(state.isGift)...[
Text(
"Your Details",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: const Color(0xff1A1A1A),
// ── Sender section (gift only) ─────────────────────────
if (state.isGift) ...[
Text(
"Your Details",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: const Color(0xff1A1A1A),
),
),
),
const SizedBox(height: 6),
Text(
state.isGift
? "Enter the address of the person who will receive this postcard"
: "Enter your contact details for this postcard.",
style: GoogleFonts.poppins(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: const Color(0xff7A7A7A),
const SizedBox(height: 6),
Text(
"Enter your details as the sender of this postcard",
style: GoogleFonts.poppins(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: const Color(0xff7A7A7A),
),
),
),
const SizedBox(height: 16),
_buildInputField(
label:"Full Name *",
hint: "Enter the full name",
controller: _senderFullNameController,
maxLength: 50,
onlyLetters: true,
keyboardType: TextInputType.name,
isFirstLetterCapital: true,
),
// _buildInputField(
// label: "Email",
// hint: "eg: Jay@gmail.com",
// controller: _senderEmailController,
// keyboardType: TextInputType.emailAddress,
// isEmail: true,
// ),
// _buildInputField(
// label: "Phone number",
// hint: "eg: 9999 999 999",
// controller: _senderPhoneController,
// keyboardType: TextInputType.number,
// maxLength: 10,
// isMobileNumber: true,
// ),
_buildInputField(
label: "City *",
hint: "Enter the name of your city",
controller: _senderCityController,
maxLength: 50,
noSpace: true,
keyboardType: TextInputType.name,
onlyLetters: true,
),
_buildDropdownField(
label: "Country *",
hint: "Select your country",
value: _senderSelectedCountry,
onChanged: (val) {
setState(() {
_senderSelectedCountry = val;
});
},
),],
// Personal details section
const SizedBox(height: 16),
_buildInputField(
label: "Full Name *",
hint: "Enter the full name",
controller: _senderFullNameController,
maxLength: 50,
onlyLetters: true,
keyboardType: TextInputType.name,
isFirstLetterCapital: true,
),
_buildInputField(
label: "City *",
hint: "Enter the name of your city",
controller: _senderCityController,
maxLength: 50,
noSpace: true,
keyboardType: TextInputType.name,
onlyLetters: true,
),
// ← Country now a plain text field (was dropdown)
_buildInputField(
label: "Country *",
hint: "Enter your country",
controller: _senderCountryController,
maxLength: 50,
onlyLetters: true,
keyboardType: TextInputType.name,
isFirstLetterCapital: true,
),
],
// ── Recipient / Self section ───────────────────────────
Text(
state.isGift ? "Recipient Details" : "Your Details",
style: TextStyle(
@@ -335,51 +360,151 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
hint: "Enter the recipient's Address",
controller: _recipientAddressController,
maxLength: 50,
// noSpecialCharacters: true,
),
// ── Zip Code with auto-fill ────────────────────────────
Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: 'Zip Code',
style: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: const Color(0xff1A1A1A),
),
children: [
TextSpan(
text: ' *',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: Colors.red,
),
),
],
),
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextFormField(
controller: _recipientZipCodeController,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: _fetchLocationFromZip,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
hintText:
"Enter the Zip Code you reside in",
counterText: "",
hintStyle: GoogleFonts.poppins(
color: const Color(0xff999999),
fontSize: 14.sp,
),
contentPadding:
const EdgeInsets.symmetric(
vertical: 14, horizontal: 12),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(
color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(
color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: const BorderSide(
color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: const BorderSide(
color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
),
validator: (value) {
if (value == null ||
value.trim().isEmpty) {
return 'Please enter Zip Code';
}
return null;
},
),
),
if (_isZipLoading)
const Padding(
padding: EdgeInsets.only(left: 10),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFFC83B61),
),
),
),
],
),
const SizedBox(height: 4),
Text(
"City, State & Country will auto-fill from zip",
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF8E8E8E),
),
),
],
),
),
// ← City, State, Country — auto-filled but editable
_buildInputField(
label: "City *",
hint: "Enter the name of your city",
controller: _recipientCityController,
maxLength: 50,
onlyLetters: true,
isFirstLetterCapital: true
),
_buildDropdownField(
label: "State *",
hint: "Select your state",
value: _recipientSelectedState,
onChanged: (val) {
setState(() {
_recipientSelectedState = val;
});
},
isFirstLetterCapital: true,
),
// ← State now a plain text field (was dropdown)
_buildInputField(
label: "Zip Code *",
hint: "Enter the Zip Code you reside in",
controller: _recipientZipCodeController,
keyboardType: TextInputType.number,
maxLength: 6,
label: "State *",
hint: "Enter your state",
controller: _recipientStateController,
maxLength: 50,
onlyLetters: true,
isFirstLetterCapital: true,
),
_buildDropdownField(
// ← Country now a plain text field (was dropdown)
_buildInputField(
label: "Country *",
hint: "Select your country",
value: _recipientSelectedCountry,
onChanged: (val) {
setState(() {
_recipientSelectedCountry = val;
});
},
hint: "Enter your country",
controller: _recipientCountryController,
maxLength: 50,
onlyLetters: true,
isFirstLetterCapital: true,
),
const SizedBox(height: 24),
// Next button
// ── Next button ───────────────────────────────────────
BlocBuilder<AddToCartPostCardBloc, AddToCartPostCardState>(
builder: (context, cartState) {
final isLoading = cartState is AddToCartPostCardLoading;
final addToCartBloc = context.read<AddToCartPostCardBloc>();
final isLoading =
cartState is AddToCartPostCardLoading;
final addToCartBloc =
context.read<AddToCartPostCardBloc>();
return SizedBox(
width: double.infinity,
@@ -390,50 +515,79 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
creationBloc.add(
UpdatePurchaseFormData(
pcTitle: _titleController.text,
fullName: _recipientFullNameController.text,
fullName:
_recipientFullNameController.text,
emailId: _senderEmailController.text,
phoneNumber: _senderPhoneController.text,
address: _recipientAddressController.text,
phoneNumber:
_senderPhoneController.text,
address:
_recipientAddressController.text,
city: _recipientCityController.text,
state: _recipientSelectedState,
zipCode: _recipientZipCodeController.text,
country: _recipientSelectedCountry,
senderName: _senderFullNameController.text,
senderCity: _senderCityController.text,
senderCountry: _senderSelectedCountry,
state: _recipientStateController.text,
zipCode:
_recipientZipCodeController.text,
country:
_recipientCountryController.text,
senderName:
_senderFullNameController.text,
senderCity:
_senderCityController.text,
senderCountry:
_senderCountryController.text,
),
);
if (_formKey.currentState!.validate()) {
final currentDate = DateFormat('yyyy-MM-dd').format(DateTime.now());
final currentDate =
DateFormat('yyyy-MM-dd')
.format(DateTime.now());
addToCartBloc.add(
AddToCartPostCardRequested(
countryName: _recipientSelectedCountry ?? '',
cityName: _recipientCityController.text,
stateName: _recipientSelectedState ?? '',
zipCode: _recipientZipCodeController.text,
address1: _recipientAddressController.text,
countryName:
_recipientCountryController.text,
cityName:
_recipientCityController.text,
stateName:
_recipientStateController.text,
zipCode:
_recipientZipCodeController.text,
address1:
_recipientAddressController.text,
address2: null,
pcTitle: _titleController.text,
pcContent: creationBloc.getFormattedMessage(),
pcImageFile: File(state.imagePath!),
pcContent: creationBloc
.getFormattedMessage(),
pcImageFile:
File(state.imagePath!),
pcNumber: '12',
pcDatetime: currentDate,
fullname: _recipientFullNameController.text,
isdCode: '+91',
fullname:
_recipientFullNameController
.text,
isdCode:
widget.initialSenderisdCode ??
"",
isForSelf: !state.isGift,
senderFullName: _senderFullNameController.text, // ⬅️ ADD
senderCityName: _senderCityController.text, // ⬅️ ADD
senderCountryName: _senderSelectedCountry,
emailAddress: _senderEmailController.text,
mobileNumber: _senderPhoneController.text,
senderFullName:
_senderFullNameController.text,
senderCityName:
_senderCityController.text,
senderCountryName:
_senderCountryController.text,
emailAddress:
widget.initialSenderEmail ??
_senderEmailController.text,
mobileNumber:
widget.initialSenderPhone ??
_senderPhoneController.text,
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xffF95F62),
padding: EdgeInsets.symmetric(vertical: 16.h),
padding:
EdgeInsets.symmetric(vertical: 16.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40),
),
@@ -444,7 +598,6 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
width: 20,
child: CircularProgressIndicator(
color: Color(0xffF95F62),
// color: Colors.white,
strokeWidth: 2,
),
)
@@ -484,7 +637,8 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
bool onlyLetters = false,
bool noSpace = false,
bool isFirstLetterCapital = false,
bool noSpecialCharacters = false, // ✅ NEW
bool noSpecialCharacters = false,
ValueChanged<String>? onChanged,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
@@ -516,6 +670,7 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
const SizedBox(height: 6),
TextFormField(
controller: controller,
onChanged: onChanged,
keyboardType: keyboardType ??
(isMobileNumber
? TextInputType.phone
@@ -525,26 +680,13 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
? TextCapitalization.words
: TextCapitalization.none,
inputFormatters: [
if (isMobileNumber)
FilteringTextInputFormatter.digitsOnly,
if (isMobileNumber) FilteringTextInputFormatter.digitsOnly,
if (onlyLetters)
FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Z ]'),
),
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z ]')),
if (noSpace)
FilteringTextInputFormatter.deny(
RegExp(r'\s'),
),
// ✅ NO SPECIAL CHARACTERS
FilteringTextInputFormatter.deny(RegExp(r'\s')),
if (noSpecialCharacters)
FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Z0-9 ]'),
),
// ✅ Capitalize first letter of each word
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9 ]')),
if (isFirstLetterCapital)
TextInputFormatter.withFunction((oldValue, newValue) {
if (newValue.text.isEmpty) return newValue;
@@ -594,7 +736,6 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
if (value == null || value.trim().isEmpty) {
return 'Please enter $label';
}
if (isEmail) {
final emailRegex =
RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
@@ -602,7 +743,6 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
return 'Please enter a valid email address';
}
}
if (isMobileNumber) {
if (!RegExp(r'^\d+$').hasMatch(value)) {
return 'Only numbers are allowed';
@@ -611,122 +751,19 @@ class _PostcardPurchaseFormPageViewState extends State<PostcardPurchaseFormPageV
return 'Mobile number must be $mobileLength digits';
}
}
if (onlyLetters) {
if (!RegExp(r'^[a-zA-Z ]+$').hasMatch(value)) {
return 'Only letters are allowed';
}
}
if (noSpace && value.contains(' ')) {
return 'Spaces are not allowed';
}
// ✅ VALIDATION FOR SPECIAL CHARACTERS
if (noSpecialCharacters) {
if (!RegExp(r'^[a-zA-Z0-9 ]+$').hasMatch(value)) {
return 'Special characters are not allowed';
}
}
return null;
},
),
],
),
);
}
/// 🔹 Dropdown input
Widget _buildDropdownField({
required String label,
required String hint,
required String? value,
required Function(String?) onChanged,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label.replaceAll(' *', ''),
style: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: const Color(0xff1A1A1A),
),
children: label.contains('*')
? [
TextSpan(
text: ' *',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: Colors.red,
),
),
]
: [],
),
),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.keyboard_arrow_down,
color: Color(0xffFDCDCE)),
hint: Text(
hint,
style: GoogleFonts.poppins(
color: const Color(0xff999999),
fontSize: 14.sp,
),
),
items: label == "Country *"
? const [
DropdownMenuItem(value: "Australia", child: Text("Australia")),
]
: label == "State *"
? const [
DropdownMenuItem(value: "New South Wales", child: Text("New South Wales")),
DropdownMenuItem(value: "Victoria", child: Text("Victoria")),
DropdownMenuItem(value: "Queensland", child: Text("Queensland")),
DropdownMenuItem(value: "South Australia", child: Text("South Australia")),
DropdownMenuItem(value: "Western Australia", child: Text("Western Australia")),
DropdownMenuItem(value: "Tasmania", child: Text("Tasmania")),
DropdownMenuItem(value: "Northern Territory", child: Text("Northern Territory")),
DropdownMenuItem(value: "Australian Capital Territory", child: Text("Australian Capital Territory")),
]
: const [
DropdownMenuItem(value: "Lorem Ipsum", child: Text("Lorem Ipsum")),
],
onChanged: onChanged,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please select $label';
}
return null;
},
),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:geocoding/geocoding.dart';
class EditYourdetails extends StatefulWidget {
final TextEditingController fullNameController;
@@ -18,6 +19,7 @@ class EditYourdetails extends StatefulWidget {
final TextEditingController senderCityController;
final String selectedSenderCountry;
final Function(String) selectSenderCountry;
const EditYourdetails({
super.key,
required this.fullNameController,
@@ -41,51 +43,76 @@ class EditYourdetails extends StatefulWidget {
}
class _EditYourdetailsState extends State<EditYourdetails> {
String? _selectedState;
String? _selectedCountry;
String? _selectedSenderCountry;
late TextEditingController _stateController;
late TextEditingController _countryController;
late TextEditingController _senderCountryController;
final List<String> countries = ['Australia'];
final List<String> states = [
'New South Wales',
'Victoria',
'Queensland',
'South Australia',
'Western Australia',
'Tasmania',
'Northern Territory',
'Australian Capital Territory',
];
bool _isZipLoading = false;
@override
void initState() {
setState(() {
_selectedState = states.contains(widget.selectedState)
? widget.selectedState
: null;
_selectedCountry = countries.contains(widget.selectedCountry)
? widget.selectedCountry
: null;
_selectedSenderCountry = countries.contains(widget.selectedSenderCountry)
? widget.selectedSenderCountry
: null;
});
super.initState();
_stateController = TextEditingController(text: widget.selectedState);
_countryController = TextEditingController(text: widget.selectedCountry);
_senderCountryController =
TextEditingController(text: widget.selectedSenderCountry);
}
@override
void dispose() {
_stateController.dispose();
_countryController.dispose();
_senderCountryController.dispose();
super.dispose();
}
// ── Zip → City, State, Country (mirrors CreateAccountView logic) ──────────
Future<void> _fetchLocationFromZip(String zip) async {
if (zip.trim().length < 4) return;
setState(() => _isZipLoading = true);
try {
final locations = await locationFromAddress(zip);
if (locations.isNotEmpty) {
final placemarks = await placemarkFromCoordinates(
locations.first.latitude,
locations.first.longitude,
);
final place = placemarks.first;
final city = place.locality ?? '';
final state = place.administrativeArea ?? '';
final country = place.country ?? '';
setState(() {
widget.cityController.text = city;
_stateController.text = state;
_countryController.text = country;
});
// Notify parent of new values
if (state.isNotEmpty) widget.selectState(state);
if (country.isNotEmpty) widget.selectCountry(country);
}
} catch (e) {
debugPrint("Zip lookup failed: $e");
} finally {
if (mounted) setState(() => _isZipLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// At the top of the Column children list, BEFORE the existing fields:
// ── Sender section (only when isForSelf == false) ──────────────────
if (!widget.isForSelf) ...[
Text(
"Your Details",
style: GoogleFonts.poppins(
color: Color(0XFF212121),
color: const Color(0XFF212121),
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
@@ -94,7 +121,7 @@ class _EditYourdetailsState extends State<EditYourdetails> {
Text(
"Enter your details as the sender of this postcard",
style: GoogleFonts.poppins(
color: Color(0XFF000000).withValues(alpha: 0.6),
color: const Color(0XFF000000).withValues(alpha: 0.6),
fontSize: 14.sp,
fontWeight: FontWeight.w400,
),
@@ -115,22 +142,22 @@ class _EditYourdetailsState extends State<EditYourdetails> {
onlyLetters: true,
noSpace: true,
),
_buildDropdownField(
_buildInputField(
label: "Country *",
hint: "Select your country",
value: _selectedSenderCountry,
items: countries,
onChanged: (val) {
setState(() => _selectedSenderCountry = val);
widget.selectSenderCountry(val!);
},
hint: "Enter your country",
controller: _senderCountryController,
maxLength: 50,
onlyLetters: true,
onChanged: (val) => widget.selectSenderCountry(val),
),
const SizedBox(height: 8),
],
// ── Recipient / Self section ───────────────────────────────────────
Text(
widget.isForSelf ? "Your Details" : "Recipient Details",
style: GoogleFonts.poppins(
color: Color(0XFF212121),
color: const Color(0XFF212121),
fontSize: 18.sp,
fontWeight: FontWeight.w500,
),
@@ -141,7 +168,7 @@ class _EditYourdetailsState extends State<EditYourdetails> {
? "Enter your address to receive this postcard"
: "Enter the address of the person who will receive this postcard",
style: GoogleFonts.poppins(
color: Color(0XFF000000).withValues(alpha: 0.6),
color: const Color(0XFF000000).withValues(alpha: 0.6),
fontSize: 14.sp,
fontWeight: FontWeight.w400,
),
@@ -160,8 +187,110 @@ class _EditYourdetailsState extends State<EditYourdetails> {
hint: "Enter the recipient's Address",
controller: widget.addressController,
maxLength: 50,
// noSpecialCharacters: true,
),
// ── Zip Code with spinner + auto-fill hint ─────────────────────────
Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: 'Zip Code',
style: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: const Color(0xff1A1A1A),
),
children: [
TextSpan(
text: ' *',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: Colors.red,
),
),
],
),
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextFormField(
controller: widget.zipCodeController,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: _fetchLocationFromZip,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
hintText: "Enter the Zip Code you reside in",
counterText: "",
hintStyle: GoogleFonts.poppins(
color: const Color(0xff999999),
fontSize: 14.sp,
),
contentPadding: const EdgeInsets.symmetric(
vertical: 14, horizontal: 12),
enabledBorder: OutlineInputBorder(
borderSide:
const BorderSide(color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide:
const BorderSide(color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter Zip Code';
}
return null;
},
),
),
if (_isZipLoading)
const Padding(
padding: EdgeInsets.only(left: 10),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFFC83B61),
),
),
),
],
),
const SizedBox(height: 4),
Text(
"City, State & Country will auto-fill from zip",
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF8E8E8E),
),
),
],
),
),
// ── City, State, Country — auto-filled but still editable ──────────
_buildInputField(
label: "City *",
hint: "Enter the name of your city",
@@ -169,36 +298,21 @@ class _EditYourdetailsState extends State<EditYourdetails> {
maxLength: 50,
onlyLetters: true,
),
_buildDropdownField(
label: "Country *",
hint: "Select your country",
value: _selectedCountry,
items: countries,
onChanged: (val) {
setState(() {
_selectedCountry = val;
});
widget.selectCountry(val!);
},
),
_buildDropdownField(
_buildInputField(
label: "State *",
hint: "Select your state",
value: _selectedState,
items: states,
onChanged: (val) {
setState(() {
_selectedState = val;
});
widget.selectState(val!);
},
hint: "Enter your state",
controller: _stateController,
maxLength: 50,
onlyLetters: true,
onChanged: (val) => widget.selectState(val),
),
_buildInputField(
label: "Zip Code *",
hint: "Enter the Zip Code you reside in",
controller: widget.zipCodeController,
keyboardType: TextInputType.number,
maxLength: 6,
label: "Country *",
hint: "Enter your country",
controller: _countryController,
maxLength: 50,
onlyLetters: true,
onChanged: (val) => widget.selectCountry(val),
),
],
);
@@ -217,7 +331,8 @@ class _EditYourdetailsState extends State<EditYourdetails> {
bool onlyLetters = false,
bool noSpace = false,
bool isFirstLetterCapital = false,
bool noSpecialCharacters = false, // ✅ NEW
bool noSpecialCharacters = false,
ValueChanged<String>? onChanged,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
@@ -249,35 +364,20 @@ class _EditYourdetailsState extends State<EditYourdetails> {
const SizedBox(height: 6),
TextFormField(
controller: controller,
onChanged: onChanged,
keyboardType: keyboardType ??
(isMobileNumber
? TextInputType.phone
: TextInputType.text),
(isMobileNumber ? TextInputType.phone : TextInputType.text),
maxLength: maxLength ?? (isMobileNumber ? mobileLength : null),
textCapitalization: isFirstLetterCapital
? TextCapitalization.words
: TextCapitalization.none,
inputFormatters: [
if (isMobileNumber)
FilteringTextInputFormatter.digitsOnly,
if (isMobileNumber) FilteringTextInputFormatter.digitsOnly,
if (onlyLetters)
FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Z ]'),
),
if (noSpace)
FilteringTextInputFormatter.deny(
RegExp(r'\s'),
),
// ✅ NO SPECIAL CHARACTERS
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z ]')),
if (noSpace) FilteringTextInputFormatter.deny(RegExp(r'\s')),
if (noSpecialCharacters)
FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Z0-9 ]'),
),
// ✅ Capitalize first letter of each word
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9 ]')),
if (isFirstLetterCapital)
TextInputFormatter.withFunction((oldValue, newValue) {
if (newValue.text.isEmpty) return newValue;
@@ -327,7 +427,6 @@ class _EditYourdetailsState extends State<EditYourdetails> {
if (value == null || value.trim().isEmpty) {
return 'Please enter $label';
}
if (isEmail) {
final emailRegex =
RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
@@ -335,7 +434,6 @@ class _EditYourdetailsState extends State<EditYourdetails> {
return 'Please enter a valid email address';
}
}
if (isMobileNumber) {
if (!RegExp(r'^\d+$').hasMatch(value)) {
return 'Only numbers are allowed';
@@ -344,24 +442,19 @@ class _EditYourdetailsState extends State<EditYourdetails> {
return 'Mobile number must be $mobileLength digits';
}
}
if (onlyLetters) {
if (!RegExp(r'^[a-zA-Z ]+$').hasMatch(value)) {
return 'Only letters are allowed';
}
}
if (noSpace && value.contains(' ')) {
return 'Spaces are not allowed';
}
// ✅ VALIDATION FOR SPECIAL CHARACTERS
if (noSpecialCharacters) {
if (!RegExp(r'^[a-zA-Z0-9 ]+$').hasMatch(value)) {
return 'Special characters are not allowed';
}
}
return null;
},
),
@@ -369,92 +462,4 @@ class _EditYourdetailsState extends State<EditYourdetails> {
),
);
}
Widget _buildDropdownField({
required String label,
required String hint,
required String? value,
required List<String> items,
required Function(String?) onChanged,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label.replaceAll(' *', ''),
style: GoogleFonts.poppins(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: const Color(0xff1A1A1A),
),
children: label.contains('*')
? [
TextSpan(
text: ' *',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: Colors.red,
),
),
]
: [],
),
),
const SizedBox(height: 6),
DropdownButtonFormField<String>(
value: value,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
vertical: 14,
horizontal: 12,
),
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Color(0xffFDCDCE)),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.red),
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xffFDCDCE),
),
hint: Text(
hint,
style: GoogleFonts.poppins(
color: const Color(0xff999999),
fontSize: 14.sp,
),
),
items: items.map((String item) {
return DropdownMenuItem<String>(value: item, child: Text(item));
}).toList(),
onChanged: onChanged,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please select $label';
}
return null;
},
),
],
),
);
}
}
}

View File

@@ -231,6 +231,7 @@ class PurchaseDetailsBottomSheet {
state: profile.stateName,
zipCode: profile.zipCode,
country: profile.country,
isdCode: profile.isdCode,
));
}

View File

@@ -21,6 +21,7 @@ class ContactUsBloc extends Bloc<ContactUsEvent, ContactUsState> {
final response = await repository.submitTicket(
firstName: event.firstName,
lastName: event.lastName,
isdCode: event.isdCode,
emailAddress: event.emailAddress,
mobileNumber: event.mobileNumber,
description: event.description,

View File

@@ -11,6 +11,7 @@ abstract class ContactUsEvent extends Equatable {
class SubmitContactUsEvent extends ContactUsEvent {
final String firstName;
final String lastName;
final String isdCode;
final String emailAddress;
final String mobileNumber;
final String description;
@@ -18,6 +19,7 @@ class SubmitContactUsEvent extends ContactUsEvent {
const SubmitContactUsEvent({
required this.firstName,
required this.lastName,
required this.isdCode,
required this.emailAddress,
required this.mobileNumber,
required this.description,
@@ -27,6 +29,7 @@ class SubmitContactUsEvent extends ContactUsEvent {
List<Object?> get props => [
firstName,
lastName,
isdCode,
emailAddress,
mobileNumber,
description,

View File

@@ -25,6 +25,7 @@ class UpdateProfileEvent extends ProfileEvent {
final String firstName;
final String lastName;
final String mobileNumber;
final String? isdCode;
final String? address1;
final String? address2;
final String? city; // ⭐ NEW
@@ -38,6 +39,7 @@ class UpdateProfileEvent extends ProfileEvent {
required this.firstName,
required this.lastName,
required this.mobileNumber,
this.isdCode,
this.address1,
this.address2,
this.city, // ⭐ NEW
@@ -53,6 +55,7 @@ class UpdateProfileEvent extends ProfileEvent {
firstName,
lastName,
mobileNumber,
isdCode,
address1,
address2,
city, // ⭐ NEW
@@ -67,6 +70,7 @@ class UpdateProfileEvent extends ProfileEvent {
'firstName': firstName,
'lastName': lastName,
'mobileNumber': mobileNumber,
if (isdCode != null && isdCode!.isNotEmpty) 'isdCode': isdCode,
if (address1 != null && address1!.isNotEmpty) 'address1': address1,
if (address2 != null && address2!.isNotEmpty) 'address2': address2,
if (city != null && city!.isNotEmpty) 'city': city, // ⭐ NEW

View File

@@ -4,9 +4,9 @@ class ProfileModel {
final String lastName;
final int roleXid;
final String emailAddress;
final String isdCode;
final String? isdCode;
final String mobileNumber;
final String? profileImage; // ✅ NEW
final String? profileImage;
final String? address1;
final String? address2;
final String? cityName;
@@ -26,7 +26,7 @@ class ProfileModel {
required this.lastName,
required this.roleXid,
required this.emailAddress,
required this.isdCode,
this.isdCode,
required this.mobileNumber,
this.profileImage,
this.address1,
@@ -50,9 +50,9 @@ class ProfileModel {
lastName: json['lastName'] ?? 'N/A',
roleXid: json['roleXid'] ?? 0,
emailAddress: json['emailAddress'] ?? 'N/A',
isdCode: json['isdCode'] ?? 'N/A',
isdCode: json['isdCode'],
mobileNumber: json['mobileNumber'] ?? 'N/A',
profileImage: json['profileImage'], // ✅ added
profileImage: json['profileImage'],
address1: json['address1'],
address2: json['address2'],
cityName: json['cityName'],

View File

@@ -8,6 +8,7 @@ class ContactUsRepository {
Future<Map<String, dynamic>> submitTicket({
required String firstName,
required String lastName,
required String isdCode,
required String emailAddress,
required String mobileNumber,
required String description,
@@ -18,6 +19,7 @@ class ContactUsRepository {
data: {
"firstName": firstName,
"lastName": lastName,
"isdCode": isdCode,
"emailAddress": emailAddress,
"mobileNumber": mobileNumber,
"description": description,

View File

@@ -60,6 +60,9 @@ class ProfileRepository {
if (data['address1'] != null && data['address1'].toString().isNotEmpty)
MapEntry('address1', data['address1']),
if (data['isdCode'] != null && data['isdCode'].toString().isNotEmpty)
MapEntry('isdCode', data['isdCode']),
if (data['address2'] != null && data['address2'].toString().isNotEmpty)
MapEntry('address2', data['address2']),

View File

@@ -2,9 +2,11 @@ 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_text.dart';
import 'package:citycards_customer/common_packages/custom_textfield.dart';
import 'package:country_code_picker/country_code_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:phone_numbers_parser/phone_numbers_parser.dart';
import '../../bloc/contactUs/contact_us_bloc.dart';
import '../../bloc/contactUs/contact_us_event.dart';
@@ -23,19 +25,36 @@ class ContactUsPage extends StatelessWidget {
}
}
class _ContactUsView extends StatelessWidget {
// ✅ Changed to StatefulWidget to hold _selectedIsdCode state
class _ContactUsView extends StatefulWidget {
const _ContactUsView();
@override
State<_ContactUsView> createState() => _ContactUsViewState();
}
class _ContactUsViewState extends State<_ContactUsView> {
final firstNameController = TextEditingController();
final lastNameController = TextEditingController();
final emailController = TextEditingController();
final phoneController = TextEditingController();
final messageController = TextEditingController();
final formKey = GlobalKey<FormState>();
String _selectedIsdCode = '+61'; // ✅ tracks selected dial code
@override
void dispose() {
firstNameController.dispose();
lastNameController.dispose();
emailController.dispose();
phoneController.dispose();
messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final firstNameController = TextEditingController();
final lastNameController = TextEditingController();
final emailController = TextEditingController();
final phoneController = TextEditingController();
final messageController = TextEditingController();
final formKey = GlobalKey<FormState>();
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
@@ -48,7 +67,6 @@ class _ContactUsView extends StatelessWidget {
backgroundColor: Colors.green,
),
);
firstNameController.clear();
lastNameController.clear();
emailController.clear();
@@ -155,8 +173,6 @@ class _ContactUsView extends StatelessWidget {
isFirstLetterCapital: true,
keyboardType: TextInputType.name,
),
/// EMAIL VALIDATION ADDED
CustomTextField(
label: "Email *",
hint: "Enter your email address",
@@ -175,22 +191,48 @@ class _ContactUsView extends StatelessWidget {
},
),
/// PHONE NUMBER VALIDATION ADDED
// ✅ Phone field with CountryCodePicker via prefixWidget
CustomTextField(
label: "Phone Number *",
hint: "Enter your phone number",
controller: phoneController,
keyboardType: TextInputType.number,
maxLength: 10,
keyboardType: TextInputType.phone,
maxLength: 12,
numbersOnly: true,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Phone number is required";
}
if (value.trim().length != 10) {
return "Enter a valid 10-digit phone number";
try {
final parsed = PhoneNumber.parse(
'$_selectedIsdCode${value.trim()}');
if (!parsed.isValid()) throw Exception();
} catch (_) {
return "Enter a valid phone number for $_selectedIsdCode";
}
return null;
},
prefixWidget: CountryCodePicker(
onChanged: (country) {
setState(() => _selectedIsdCode = country.dialCode!);
},
initialSelection: 'AU',
favorite: const ['+61', '+1', '+44', '+91'],
showCountryOnly: false,
showOnlyCountryWhenClosed: false,
alignLeft: false,
flagWidth: 24.w,
padding: EdgeInsets.symmetric(horizontal: 8.w),
textStyle: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF2D3134),
),
dialogTextStyle: TextStyle(fontSize: 14.sp),
searchDecoration: const InputDecoration(
hintText: 'Search country...',
prefixIcon: Icon(Icons.search),
),
),
),
CustomTextField(
@@ -221,22 +263,17 @@ class _ContactUsView extends StatelessWidget {
onPressed: isLoading
? null
: () {
if (!formKey.currentState!.validate()) {
return;
}
if (!formKey.currentState!.validate()) return;
context.read<ContactUsBloc>().add(
SubmitContactUsEvent(
firstName:
firstNameController.text.trim(),
lastName:
lastNameController.text.trim(),
emailAddress:
emailController.text.trim(),
mobileNumber:
phoneController.text.trim(),
description:
messageController.text.trim(),
lastName: lastNameController.text.trim(),
isdCode: _selectedIsdCode,
emailAddress: emailController.text.trim(),
mobileNumber: phoneController.text.trim(),
description: messageController.text.trim(),
),
);
},
@@ -269,7 +306,6 @@ class _ContactUsView extends StatelessWidget {
);
}
/// Support Box Widget
static Widget _supportBox({
required IconData icon,
required String title,

View File

@@ -2,12 +2,15 @@ import 'dart:io';
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:country_code_picker/country_code_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geocoding/geocoding.dart';
import 'package:image_picker/image_picker.dart';
import 'package:phone_numbers_parser/phone_numbers_parser.dart';
import '../../../localPreference/local_preference.dart';
import '../../../networkApiServices/api_urls.dart';
@@ -33,10 +36,14 @@ class _EditProfilePageState extends State<EditProfilePage> {
final TextEditingController address2Controller = TextEditingController();
final TextEditingController cityController = TextEditingController();
final TextEditingController zipCodeController = TextEditingController();
final TextEditingController stateController = TextEditingController();
final TextEditingController countryController = TextEditingController();
// Dropdown values
String? selectedState;
String? selectedCountry;
String _selectedIsdCode = '';
Key _countryPickerKey = UniqueKey(); // ADD
String _selectedCountryCode = 'AU';
bool _isZipLoading = false;
final _formKey = GlobalKey<FormState>();
final ImagePicker _picker = ImagePicker();
@@ -47,6 +54,30 @@ class _EditProfilePageState extends State<EditProfilePage> {
_fetchProfile();
}
Future<void> fetchLocationFromZip(String zip) async {
if (zip.trim().length < 4) return;
setState(() => _isZipLoading = true);
try {
List<Location> locations = await locationFromAddress(zip);
if (locations.isNotEmpty) {
List<Placemark> placemarks = await placemarkFromCoordinates(
locations.first.latitude,
locations.first.longitude,
);
final place = placemarks.first;
setState(() {
cityController.text = place.locality ?? '';
stateController.text = place.administrativeArea ?? '';
countryController.text = place.country ?? '';
});
}
} catch (e) {
debugPrint("Zip lookup failed: $e");
} finally {
if (mounted) setState(() => _isZipLoading = false);
}
}
Future<void> _fetchProfile() async {
if (kDebugMode) {
print('🔵 [EDIT PROFILE] Fetching profile...');
@@ -74,11 +105,13 @@ class _EditProfilePageState extends State<EditProfilePage> {
address2Controller.text = profile.address2 ?? '';
cityController.text = profile.cityName ?? '';
zipCodeController.text = profile.zipCode ?? '';
stateController.text = profile.stateName ?? '';
countryController.text = profile.country ?? '';
// Set dropdown values from fetched data
setState(() {
selectedState = profile.stateName;
selectedCountry = profile.country;
_selectedIsdCode = profile.isdCode??"";
_selectedCountryCode = profile.isdCode ?? '+61'; // ADD
_countryPickerKey = UniqueKey();
});
// ⭐ REMOVED setState - image is now managed by BLoC state
@@ -313,6 +346,28 @@ class _EditProfilePageState extends State<EditProfilePage> {
if (!mounted) return;
// Phone validation
final phone = phoneController.text.trim();
bool isValidPhone = false;
try {
final fullNumber = '$_selectedIsdCode$phone';
final parsed = PhoneNumber.parse(fullNumber);
isValidPhone = parsed.isValid();
} catch (_) {
isValidPhone = false;
}
if (!isValidPhone) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Enter a valid phone number for $_selectedIsdCode'),
backgroundColor: Colors.red,
),
);
return;
}
// ⭐ Get selectedImageFile from current BLoC state
File? imageFileToSend;
final currentState = context.read<ProfileBloc>().state;
@@ -330,7 +385,8 @@ class _EditProfilePageState extends State<EditProfilePage> {
userId: userId,
firstName: firstNameController.text.trim(),
lastName: lastNameController.text.trim(),
mobileNumber: phoneController.text.trim(),
mobileNumber: phone,
isdCode: _selectedIsdCode,
address1: address1Controller.text.trim().isEmpty
? null
: address1Controller.text.trim(),
@@ -341,8 +397,8 @@ class _EditProfilePageState extends State<EditProfilePage> {
city: cityController.text.trim().isEmpty
? null
: cityController.text.trim(),
state: selectedState,
country: selectedCountry,
state: stateController.text.trim().isEmpty ? null : stateController.text.trim(),
country: countryController.text.trim().isEmpty ? null : countryController.text.trim(),
postalCode: zipCodeController.text.trim().isEmpty
? null
: zipCodeController.text.trim(),
@@ -360,6 +416,8 @@ class _EditProfilePageState extends State<EditProfilePage> {
address2Controller.dispose();
cityController.dispose();
zipCodeController.dispose();
stateController.dispose();
countryController.dispose();
super.dispose();
}
@@ -516,18 +574,36 @@ class _EditProfilePageState extends State<EditProfilePage> {
label: "Phone Number *",
hint: "Enter your phone number",
controller: phoneController,
keyboardType: TextInputType.phone,
maxLength: 12,
numbersOnly: true,
enabled: !isLoading,
keyboardType: TextInputType.number,
maxLength: 10,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return "Phone number is required";
}
if (value.trim().length != 10) {
return "Enter a valid 10-digit phone number";
}
return null;
},
prefixWidget: CountryCodePicker(
key: _countryPickerKey,
onChanged: isLoading
? null
: (country) {
setState(() => _selectedIsdCode = country.dialCode!);
},
initialSelection: _selectedCountryCode,
favorite: const ['+61', '+1', '+44', '+91'],
showCountryOnly: false,
showOnlyCountryWhenClosed: false,
alignLeft: false,
flagWidth: 24.w,
padding: EdgeInsets.symmetric(horizontal: 8.w),
textStyle: TextStyle(
fontSize: 13.sp,
color: isLoading
? const Color(0xFF8E8E8E)
: const Color(0xFF2D3134),
),
dialogTextStyle: TextStyle(fontSize: 14.sp),
searchDecoration: const InputDecoration(
hintText: 'Search country...',
prefixIcon: Icon(Icons.search),
),
),
),
),
@@ -568,129 +644,45 @@ class _EditProfilePageState extends State<EditProfilePage> {
),
Padding(
padding: EdgeInsets.only(bottom: 12.h, left: 12.w, right: 12.w),
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(text: "State *", size: 14.sp),
SizedBox(height: 6.h),
Container(
height: 42.h,
padding: EdgeInsets.symmetric(horizontal: 24.w),
decoration: BoxDecoration(
color: const Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: const Color(0xBBC83B61).withOpacity(0.4),
width: 0.4.w,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedState,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8E8E8E),
Row(
children: [
Expanded(
child: CustomTextField(
controller: zipCodeController,
enabled: !isLoading,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: fetchLocationFromZip,
label: 'Zip Code *',
hint: 'Enter the ZIP code you reside in',
),
hint: Text(
"Select state",
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF8E8E8E),
),
if (_isZipLoading)
Padding(
padding: EdgeInsets.only(right: 12.w),
child: SizedBox(
width: 18.w,
height: 18.h,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFFF95F62),
),
),
),
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF2D3134),
),
onChanged: isLoading ? null : (value) {
setState(() {
selectedState = value;
});
},
items: [
"New South Wales",
"Victoria",
"Queensland",
"South Australia",
"Western Australia",
"Tasmania",
"Northern Territory",
"Australian Capital Territory"
].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
],
),
// Text(
// "City, State & Country will auto-fill from zip",
// style: TextStyle(fontSize: 10.sp, color: const Color(0xFF8E8E8E)),
// ),
],
),
),
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: isLoading ? null : (value) {
setState(() {
selectedCountry = value;
});
},
items: ["Australia"].map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: TextStyle(fontSize: 14.sp),
),
);
}).toList(),
),
),
),
],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
@@ -701,21 +693,37 @@ class _EditProfilePageState extends State<EditProfilePage> {
enabled: !isLoading,
maxLength: 50,
onlyLetters: true,
isPreview: true,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0.w),
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "ZIP Code *",
hint: "Enter the ZIP code you reside in",
controller: zipCodeController,
label: "State *",
hint: "Enter your state",
controller: stateController,
enabled: !isLoading,
keyboardType: TextInputType.number,
maxLength: 6,
maxLength: 50,
isFirstLetterCapital: true,
isPreview: true,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: CustomTextField(
label: "Country *",
hint: "Enter your country",
controller: countryController,
enabled: !isLoading,
maxLength: 50,
isPreview: true,
isFirstLetterCapital: true,
),
),
SizedBox(height: 26.h),
// Buttons

View File

@@ -1,5 +1,7 @@
class YourItineraryDetailsModel {
final int id;
final String userFirstName;
final String validUpto;
final String title;
final String city;
final String cityBanner;
@@ -11,6 +13,8 @@ class YourItineraryDetailsModel {
YourItineraryDetailsModel({
required this.id,
required this.userFirstName,
required this.validUpto,
required this.title,
required this.city,
required this.cityBanner,
@@ -21,17 +25,19 @@ class YourItineraryDetailsModel {
required this.days,
});
factory YourItineraryDetailsModel.fromJson(Map<String, dynamic>? json) {
factory YourItineraryDetailsModel.fromJson(Map<String, dynamic> json) {
return YourItineraryDetailsModel(
id: json?['id'] ?? 0,
title: json?['title'] ?? "",
city: json?['city'] ?? "",
cityBanner: json?['cityBanner'] ?? "",
totalDays: json?['totalDays'] ?? 0,
totalStops: json?['totalStops'] ?? 0,
adults: json?['adults'] ?? 0,
children: json?['children'] ?? 0,
days: (json?['days'] as List?)
id: json['id'] ?? 0,
userFirstName: json['userFirstName'] ?? "",
validUpto: json['validUpto'] ?? "",
title: json['title'] ?? "",
city: json['city'] ?? "",
cityBanner: json['cityBanner'] ?? "",
totalDays: json['totalDays'] ?? 0,
totalStops: json['totalStops'] ?? 0,
adults: json['adults'] ?? 0,
children: json['children'] ?? 0,
days: (json['days'] as List?)
?.map((e) => ItineraryDay.fromJson(e))
.toList() ??
[],
@@ -52,12 +58,12 @@ class ItineraryDay {
required this.items,
});
factory ItineraryDay.fromJson(Map<String, dynamic>? json) {
factory ItineraryDay.fromJson(Map<String, dynamic> json) {
return ItineraryDay(
dayNumber: json?['dayNumber'] ?? 0,
title: json?['title'] ?? "",
date: json?['date'] ?? "",
items: (json?['items'] as List?)
dayNumber: json['dayNumber'] ?? 0,
title: json['title'] ?? "",
date: json['date'] ?? "",
items: (json['items'] as List?)
?.map((e) => ItineraryItem.fromJson(e))
.toList() ??
[],
@@ -92,21 +98,20 @@ class ItineraryItem {
required this.attractionXid,
});
factory ItineraryItem.fromJson(Map<String, dynamic>? json) {
factory ItineraryItem.fromJson(Map<String, dynamic> json) {
return ItineraryItem(
id: json?['id'] ?? 0,
itineraryDayXid: json?['itineraryDayXid'] ?? 0,
timeSlot: json?['timeSlot'] ?? "",
title: json?['title'] ?? "",
description: json?['description'] ?? "",
locationName: json?['locationName'] ?? "",
id: json['id'] ?? 0,
itineraryDayXid: json['itineraryDayXid'] ?? 0,
timeSlot: json['timeSlot'] ?? "",
title: json['title'] ?? "",
description: json['description'] ?? "",
locationName: json['locationName'] ?? "",
categories:
(json?['categories'] as List?)?.map((e) => e.toString()).toList() ??
[],
imageUrl: json?['imageUrl'] ?? "",
latitude: (json?['latitude'] ?? 0).toDouble(),
longitude: (json?['longitude'] ?? 0).toDouble(),
attractionXid: json?['attractionXid'],
(json['categories'] as List?)?.map((e) => e.toString()).toList() ?? [],
imageUrl: json['imageUrl'] ?? "",
latitude: (json['latitude'] ?? 0).toDouble(),
longitude: (json['longitude'] ?? 0).toDouble(),
attractionXid: json['attractionXid'],
);
}
}

View File

@@ -165,7 +165,7 @@ class _YourItineraryViewState extends State<YourItineraryView> {
// Title
Text(
'Your',
"${itinerary.userFirstName}'s",
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w700,
@@ -256,9 +256,7 @@ class _YourItineraryViewState extends State<YourItineraryView> {
),
SizedBox(width: 2.w),
Text(
itinerary.days.isNotEmpty
? itinerary.days.first.date
: 'N/A',
itinerary.validUpto,
style: TextStyle(
fontSize: 10.5.sp,
color: Color(0xFF6B7280)),

View File

@@ -60,25 +60,25 @@ class SummaryCard extends StatelessWidget {
weight: FontWeight.w500,
color: const Color(0xFF212121),
),
SizedBox(width: 16.w),
Spacer(), // 👈 Add this
Row(
children: [
Image.asset(
"assets/icons/calender_filled.png",
color: const Color(0xFFF95F62),
width: 20.sp,
width: 16.sp, // 👈 slightly smaller (was 20.sp)
),
SizedBox(width: 4.w),
CustomText(
text: date,
color: const Color(0xFFF95F62),
size: 16.sp,
size: 14.sp, // 👈 slightly smaller (was 16.sp)
weight: FontWeight.w500,
),
],
),
],
),
),
SizedBox(height: 15.h),

View File

@@ -113,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
country_code_picker:
dependency: "direct main"
description:
name: country_code_picker
sha256: f0411f4833b6f98e8b7215f4fa3813bcc88e50f13925f70a170dbd36e3e447f5
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cross_file:
dependency: transitive
description:
@@ -177,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
diacritic:
dependency: transitive
description:
name: diacritic
sha256: "12981945ec38931748836cd76f2b38773118d0baef3c68404bdfde9566147876"
url: "https://pub.dev"
source: hosted
version: "0.1.6"
dio:
dependency: "direct main"
description:
@@ -685,6 +701,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
@@ -725,6 +749,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
libphonenumber_platform_interface:
dependency: transitive
description:
name: libphonenumber_platform_interface
sha256: f801f6c65523f56504b83f0890e6dad584ab3a7507dca65fec0eed640afea40f
url: "https://pub.dev"
source: hosted
version: "0.4.2"
libphonenumber_plugin:
dependency: "direct main"
description:
name: libphonenumber_plugin
sha256: c615021d9816fbda2b2587881019ed595ecdf54d999652d7e4cce0e1f026368c
url: "https://pub.dev"
source: hosted
version: "0.3.3"
libphonenumber_web:
dependency: transitive
description:
name: libphonenumber_web
sha256: "8186f420dbe97c3132283e52819daff1e55d60d6db46f7ea5ac42f42a28cc2ef"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
lints:
dependency: transitive
description:
@@ -909,6 +957,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
phone_numbers_parser:
dependency: "direct main"
description:
name: phone_numbers_parser
sha256: c30ec1a8ee216da8631eb32d6c3ce0fec85c9accb221c8868bb0aa90c0ce5e95
url: "https://pub.dev"
source: hosted
version: "9.0.20"
platform:
dependency: transitive
description:
@@ -949,6 +1005,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
rxdart:
dependency: transitive
description:

View File

@@ -67,6 +67,10 @@ dependencies:
google_mlkit_translation: ^0.13.1
url_launcher: ^6.3.2
open_filex: ^4.7.0
country_code_picker: ^3.4.1
libphonenumber_plugin: ^0.3.3
phone_numbers_parser: ^9.0.20
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test: