added check in and Qr scaner and bookng with api and more fixes and changes
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -85,7 +85,7 @@ class ItineraryCreationStartPage extends StatelessWidget {
|
||||
label: "Let’s explore together!",
|
||||
),
|
||||
|
||||
SizedBox(height: 35.h),
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
/// Footer Text
|
||||
CustomText(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
40
lib/my_pass/blocs/checkIn/check_in_bloc.dart
Normal file
40
lib/my_pass/blocs/checkIn/check_in_bloc.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
27
lib/my_pass/blocs/checkIn/check_in_event.dart
Normal file
27
lib/my_pass/blocs/checkIn/check_in_event.dart
Normal 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();
|
||||
}
|
||||
38
lib/my_pass/blocs/checkIn/check_in_state.dart
Normal file
38
lib/my_pass/blocs/checkIn/check_in_state.dart
Normal 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];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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});
|
||||
}
|
||||
282
lib/my_pass/models/pass_attraction_details_model.dart
Normal file
282
lib/my_pass/models/pass_attraction_details_model.dart
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/my_pass/repository/check_in_repository.dart
Normal file
15
lib/my_pass/repository/check_in_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
27
lib/my_pass/repository/make_booking_repository.dart
Normal file
27
lib/my_pass/repository/make_booking_repository.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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},
|
||||
);
|
||||
}),
|
||||
|
||||
|
||||
215
lib/my_pass/widgets/check_in_bottom_sheet.dart
Normal file
215
lib/my_pass/widgets/check_in_bottom_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
101
lib/my_pass/widgets/how_to_redeem_bottomsheet.dart
Normal file
101
lib/my_pass/widgets/how_to_redeem_bottomsheet.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,7 @@ class PurchaseDetailsBottomSheet {
|
||||
state: profile.stateName,
|
||||
zipCode: profile.zipCode,
|
||||
country: profile.country,
|
||||
isdCode: profile.isdCode,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
72
pubspec.lock
72
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user