Api Integrated in regitsered home page and in attarction and attraction details pages and there are chnages are there from backend they are pending.
This commit is contained in:
@@ -1,484 +0,0 @@
|
||||
import 'package:citycards_customer/attraction_details/share_bottomsheet.dart';
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../core/route_constants.dart';
|
||||
|
||||
class AttractionDetailsView extends StatelessWidget {
|
||||
const AttractionDetailsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Stack(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/koh_rong_samloem_banner.png',
|
||||
height: 377.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
CommonAppBar(isWhiteLogo: true, isProfilePage: false, showDivider: true,),
|
||||
|
||||
SizedBox(height: 10.h),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
size: 24.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
"Koh Rong Samloem",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
left: 12.w,
|
||||
child: Text(
|
||||
"Koh Rong\nSamloem",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 44.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
right: 17.w,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const ShareBottomSheet(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 36.h,
|
||||
width: 36.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.share_sharp,
|
||||
color: Colors.black,
|
||||
size: 18.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// About Section
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 30.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"About",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.32.h),
|
||||
Text(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non...",
|
||||
style: TextStyle(
|
||||
color: Color(0xFF262626),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 14.sp,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 41.h),
|
||||
// Booking Section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"How to make a booking?",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.call,
|
||||
color: Color(0xFFF95F62),
|
||||
size: 32.w,
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Contact Number",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: "+1012 3456 789",
|
||||
color: Colors.black,
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w600,
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: "Tap to call",
|
||||
color: Colors.black.withOpacity(.4),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.email_sharp,
|
||||
color: Color(0xFFF95F62),
|
||||
size: 32.w,
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Email",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: "CityCards24@gmail.com",
|
||||
color: Colors.black,
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w600,
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: "Tap to email",
|
||||
color: Colors.black.withOpacity(.4),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
InkWell(
|
||||
onTap: (){
|
||||
Navigator.of(context).pushNamed(RouteConstants.makeBooking);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 24.w,
|
||||
vertical: 18.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Via CityCards",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: "Create a booking via app",
|
||||
size: 11.sp,
|
||||
weight: FontWeight.w400,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
"What is included",
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
|
||||
Wrap(
|
||||
runSpacing: 16.h,
|
||||
spacing: 16.w,
|
||||
children: [
|
||||
includedBox(
|
||||
"assets/icons/bus.png",
|
||||
"Bus",
|
||||
"Transportation",
|
||||
),
|
||||
includedBox(
|
||||
"assets/icons/clock.png",
|
||||
"2 day 1 night",
|
||||
"Duration",
|
||||
),
|
||||
includedBox(
|
||||
"assets/icons/bx_qr.png",
|
||||
"TAC200812695",
|
||||
"Product code",
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
"Exact Location",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
CustomText(
|
||||
text: "View the location on map",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(.6),
|
||||
),
|
||||
SizedBox(height: 17.h),
|
||||
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(13.54.r),
|
||||
child: Image.asset(
|
||||
height: 178.7.h,
|
||||
width: double.infinity,
|
||||
"assets/images/attra_detail_map.png",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 17.h),
|
||||
|
||||
CustomText(
|
||||
text:
|
||||
"Angkor Mails Hotel \nNR6, Krong Siem Reap Cambodia",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
|
||||
SizedBox(height: 30.h),
|
||||
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
"People frequently ask",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 15.h),
|
||||
|
||||
faqBox(
|
||||
"About this place",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
|
||||
SizedBox(height: 15.h),
|
||||
|
||||
faqBox(
|
||||
"Term and condition",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
|
||||
faqBox(
|
||||
"Cancellation Policy",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget includedBox(String icon, String title, String disc) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
border: Border.all(color: Color(0xFFFDCDCE)),
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(icon, scale: 4),
|
||||
SizedBox(width: 16.w),
|
||||
Column(
|
||||
children: [
|
||||
CustomText(
|
||||
text: title,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFF212121),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
CustomText(
|
||||
text: disc,
|
||||
size: 11.sp,
|
||||
weight: FontWeight.w400,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget faqBox(String title, String desc) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
border: Border.all(color: Color(0xFFFDCDCE)),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CustomText(
|
||||
text: title,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFF212121),
|
||||
),
|
||||
SizedBox(width: 20.w),
|
||||
Icon(Icons.arrow_forward_ios_outlined, size: 18.sp),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 9.h),
|
||||
CustomText(text: desc, size: 11.sp, color: Color(0xFF7D7D7D)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/attraction_details/bloc/attraction_details_bloc.dart
Normal file
40
lib/attraction_details/bloc/attraction_details_bloc.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'attraction_details_event.dart';
|
||||
import 'attraction_details_state.dart';
|
||||
import '../repository/attraction_details_repository.dart';
|
||||
|
||||
class AttractionDetailsBloc
|
||||
extends Bloc<AttractionDetailsEvent, AttractionDetailsState> {
|
||||
final AttractionDetailsRepository repository;
|
||||
|
||||
AttractionDetailsBloc({
|
||||
required this.repository,
|
||||
}) : super(AttractionDetailsInitial()) {
|
||||
on<FetchAttractionDetails>(_onFetchAttractionDetails);
|
||||
}
|
||||
|
||||
Future<void> _onFetchAttractionDetails(
|
||||
FetchAttractionDetails event,
|
||||
Emitter<AttractionDetailsState> emit,
|
||||
) async {
|
||||
emit(AttractionDetailsLoading());
|
||||
|
||||
try {
|
||||
final response = await repository.fetchAttractionDetails(
|
||||
attractionId: event.attractionId,
|
||||
);
|
||||
|
||||
emit(
|
||||
AttractionDetailsLoaded(
|
||||
attractionDetails: response,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AttractionDetailsError(
|
||||
message: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
lib/attraction_details/bloc/attraction_details_event.dart
Normal file
19
lib/attraction_details/bloc/attraction_details_event.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class AttractionDetailsEvent extends Equatable {
|
||||
const AttractionDetailsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FetchAttractionDetails extends AttractionDetailsEvent {
|
||||
final int attractionId;
|
||||
|
||||
const FetchAttractionDetails({
|
||||
required this.attractionId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [attractionId];
|
||||
}
|
||||
36
lib/attraction_details/bloc/attraction_details_state.dart
Normal file
36
lib/attraction_details/bloc/attraction_details_state.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../models/attraction_details_model.dart';
|
||||
|
||||
abstract class AttractionDetailsState extends Equatable {
|
||||
const AttractionDetailsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AttractionDetailsInitial extends AttractionDetailsState {}
|
||||
|
||||
class AttractionDetailsLoading extends AttractionDetailsState {}
|
||||
|
||||
class AttractionDetailsLoaded extends AttractionDetailsState {
|
||||
final AttractionDetailsModel attractionDetails;
|
||||
|
||||
const AttractionDetailsLoaded({
|
||||
required this.attractionDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [attractionDetails];
|
||||
}
|
||||
|
||||
class AttractionDetailsError extends AttractionDetailsState {
|
||||
final String message;
|
||||
|
||||
const AttractionDetailsError({
|
||||
required this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
243
lib/attraction_details/models/attraction_details_model.dart
Normal file
243
lib/attraction_details/models/attraction_details_model.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
class AttractionDetailsModel {
|
||||
final int id;
|
||||
final String title;
|
||||
final String description;
|
||||
final int cityXid;
|
||||
final int? cardTypeXid;
|
||||
final int partnerXid;
|
||||
final String productCode;
|
||||
final String subTitle;
|
||||
final String urlSlug;
|
||||
final bool isBookingRequired;
|
||||
final bool isPartnerAccess;
|
||||
final String bookingEmail;
|
||||
final String bookingPhoneNumber;
|
||||
final String address;
|
||||
final double latitudeCoordinate;
|
||||
final double longitudeCoordinate;
|
||||
final double ticketPriceAdult;
|
||||
final double ticketPriceChild;
|
||||
final int durations;
|
||||
final int groupSize;
|
||||
final String ageRange;
|
||||
final String seoTitle;
|
||||
final String seoDescription;
|
||||
final String attractionStatus;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<AttractionGallery> attractionGalleries;
|
||||
final List<AttractionInclusion> attractionInclusions;
|
||||
final List<AttractionFaq> attractionFaqs;
|
||||
|
||||
AttractionDetailsModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.cityXid,
|
||||
this.cardTypeXid,
|
||||
required this.partnerXid,
|
||||
required this.productCode,
|
||||
required this.subTitle,
|
||||
required this.urlSlug,
|
||||
required this.isBookingRequired,
|
||||
required this.isPartnerAccess,
|
||||
required this.bookingEmail,
|
||||
required this.bookingPhoneNumber,
|
||||
required this.address,
|
||||
required this.latitudeCoordinate,
|
||||
required this.longitudeCoordinate,
|
||||
required this.ticketPriceAdult,
|
||||
required this.ticketPriceChild,
|
||||
required this.durations,
|
||||
required this.groupSize,
|
||||
required this.ageRange,
|
||||
required this.seoTitle,
|
||||
required this.seoDescription,
|
||||
required this.attractionStatus,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.attractionGalleries,
|
||||
required this.attractionInclusions,
|
||||
required this.attractionFaqs,
|
||||
});
|
||||
|
||||
factory AttractionDetailsModel.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionDetailsModel(
|
||||
id: json['id'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
cityXid: json['cityXid'] ?? 0,
|
||||
cardTypeXid: json['cardTypeXid'],
|
||||
partnerXid: json['partnerXid'] ?? 0,
|
||||
productCode: json['productCode'] ?? 'N/A',
|
||||
subTitle: json['subTitle'] ?? 'N/A',
|
||||
urlSlug: json['urlSlug'] ?? 'N/A',
|
||||
isBookingRequired: json['isBookingRequired'] ?? false,
|
||||
isPartnerAccess: json['isPartnerAccess'] ?? false,
|
||||
bookingEmail: json['bookingEmail'] ?? 'N/A',
|
||||
bookingPhoneNumber: json['bookingPhoneNumber'] ?? 'N/A',
|
||||
address: json['address'] ?? 'N/A',
|
||||
latitudeCoordinate: json['latitudeCoordinate'] != null
|
||||
? (json['latitudeCoordinate'] as num).toDouble()
|
||||
: 0.0,
|
||||
longitudeCoordinate: json['longitudeCoordinate'] != null
|
||||
? (json['longitudeCoordinate'] as num).toDouble()
|
||||
: 0.0,
|
||||
ticketPriceAdult: json['ticketPriceAdult'] != null
|
||||
? (json['ticketPriceAdult'] as num).toDouble()
|
||||
: 0.0,
|
||||
ticketPriceChild: json['ticketPriceChild'] != null
|
||||
? (json['ticketPriceChild'] as num).toDouble()
|
||||
: 0.0,
|
||||
durations: json['durations'] ?? 0,
|
||||
groupSize: json['groupSize'] ?? 0,
|
||||
ageRange: json['ageRange'] ?? 'N/A',
|
||||
seoTitle: json['seoTitle'] ?? 'N/A',
|
||||
seoDescription: json['seoDescription'] ?? 'N/A',
|
||||
attractionStatus: json['attractionStatus'] ?? 'N/A',
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: DateTime.now(),
|
||||
attractionGalleries: json['attractionGalleries'] != null
|
||||
? (json['attractionGalleries'] as List)
|
||||
.map((e) => AttractionGallery.fromJson(e))
|
||||
.toList()
|
||||
: [],
|
||||
attractionInclusions: json['attractionInclusions'] != null
|
||||
? (json['attractionInclusions'] as List)
|
||||
.map((e) => AttractionInclusion.fromJson(e))
|
||||
.toList()
|
||||
: [],
|
||||
attractionFaqs: json['attractionFaqs'] != null
|
||||
? (json['attractionFaqs'] as List)
|
||||
.map((e) => AttractionFaq.fromJson(e))
|
||||
.toList()
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// =======================
|
||||
/// Attraction Gallery
|
||||
/// =======================
|
||||
class AttractionGallery {
|
||||
final int id;
|
||||
final int attractionXid;
|
||||
final String fileType;
|
||||
final String filePathUrl;
|
||||
final String altText;
|
||||
final bool isCoverImage;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
AttractionGallery({
|
||||
required this.id,
|
||||
required this.attractionXid,
|
||||
required this.fileType,
|
||||
required this.filePathUrl,
|
||||
required this.altText,
|
||||
required this.isCoverImage,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory AttractionGallery.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionGallery(
|
||||
id: json['id'] ?? 0,
|
||||
attractionXid: json['attractionXid'] ?? 0,
|
||||
fileType: json['fileType'] ?? 'N/A',
|
||||
filePathUrl: json['filePathUrl'] ?? 'N/A',
|
||||
altText: json['altText'] ?? 'N/A',
|
||||
isCoverImage: json['isCoverImage'] ?? false,
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// =======================
|
||||
/// Attraction Inclusion
|
||||
/// =======================
|
||||
class AttractionInclusion {
|
||||
final int id;
|
||||
final int attractionXid;
|
||||
final String title;
|
||||
final String description;
|
||||
final int? iconXid;
|
||||
final bool isInclusion;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
AttractionInclusion({
|
||||
required this.id,
|
||||
required this.attractionXid,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.iconXid,
|
||||
required this.isInclusion,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory AttractionInclusion.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionInclusion(
|
||||
id: json['id'] ?? 0,
|
||||
attractionXid: json['attractionXid'] ?? 0,
|
||||
title: json['title'] ?? 'N/A',
|
||||
description: json['description'] ?? 'N/A',
|
||||
iconXid: json['iconXid'],
|
||||
isInclusion: json['isInclusion'] ?? false,
|
||||
isActive: json['isActive'] ?? false,
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// =======================
|
||||
/// Attraction FAQ
|
||||
/// =======================
|
||||
class AttractionFaq {
|
||||
final int id;
|
||||
final int attractionXid;
|
||||
final String question;
|
||||
final String answer;
|
||||
final bool isActive;
|
||||
|
||||
AttractionFaq({
|
||||
required this.id,
|
||||
required this.attractionXid,
|
||||
required this.question,
|
||||
required this.answer,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory AttractionFaq.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionFaq(
|
||||
id: json['id'] ?? 0,
|
||||
attractionXid: json['attractionXid'] ?? 0,
|
||||
question: json['question'] ?? 'N/A',
|
||||
answer: json['answer'] ?? 'N/A',
|
||||
isActive: json['isActive'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../models/attraction_details_model.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
class AttractionDetailsRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
/// Fetch attraction details by attractionId
|
||||
Future<AttractionDetailsModel> fetchAttractionDetails({
|
||||
required int attractionId,
|
||||
}) async {
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.attractionDetails}/$attractionId',
|
||||
);
|
||||
|
||||
return AttractionDetailsModel.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
541
lib/attraction_details/views/attraction_details_view.dart
Normal file
541
lib/attraction_details/views/attraction_details_view.dart
Normal file
@@ -0,0 +1,541 @@
|
||||
import 'package:citycards_customer/attraction_details/widgets/share_bottomsheet.dart';
|
||||
import 'package:citycards_customer/common_packages/app_bar.dart';
|
||||
import 'package:citycards_customer/common_packages/custom_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../core/route_constants.dart';
|
||||
import '../bloc/attraction_details_bloc.dart';
|
||||
import '../bloc/attraction_details_event.dart';
|
||||
import '../bloc/attraction_details_state.dart';
|
||||
import '../repository/attraction_details_repository.dart';
|
||||
|
||||
class AttractionDetailsView extends StatelessWidget {
|
||||
final int? attractionId;
|
||||
|
||||
const AttractionDetailsView({
|
||||
super.key,
|
||||
required this.attractionId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => AttractionDetailsBloc(
|
||||
repository: AttractionDetailsRepository(),
|
||||
)..add(FetchAttractionDetails(attractionId: attractionId??0)),
|
||||
child: BlocBuilder<AttractionDetailsBloc, AttractionDetailsState>(
|
||||
builder: (context, state) {
|
||||
if (state is AttractionDetailsLoading) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is AttractionDetailsError) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: Text(
|
||||
state.message,
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is AttractionDetailsLoaded) {
|
||||
final attraction = state.attractionDetails;
|
||||
final coverImage = attraction.attractionGalleries
|
||||
.firstWhere(
|
||||
(gallery) => gallery.isCoverImage,
|
||||
orElse: () => attraction.attractionGalleries.first,
|
||||
)
|
||||
.filePathUrl;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Image.network(
|
||||
coverImage,
|
||||
height: 377.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Image.asset(
|
||||
'assets/images/koh_rong_samloem_banner.png',
|
||||
height: 377.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w, vertical: 10.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: true,
|
||||
isProfilePage: false,
|
||||
showDivider: true,
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
size: 24.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
attraction.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
left: 12.w,
|
||||
right: 60.w, // Add this - leaves space for share button
|
||||
child: Text(
|
||||
attraction.title,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 44.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 31.h,
|
||||
right: 17.w,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) =>
|
||||
const ShareBottomSheet(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 36.h,
|
||||
width: 36.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.share_sharp,
|
||||
color: Colors.black,
|
||||
size: 18.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// About Section
|
||||
Padding(
|
||||
padding:
|
||||
EdgeInsets.only(left: 16.w, right: 16.w, top: 20.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"About",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.32.h),
|
||||
Text(
|
||||
attraction.description,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF262626),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 14.sp,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 41.h),
|
||||
|
||||
// Booking Section
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"How to make a booking?",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.call,
|
||||
color: Color(0xFFF95F62),
|
||||
size: 32.w,
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Contact Number",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: attraction.bookingPhoneNumber??"N/A",
|
||||
color: Colors.black,
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w600,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: "Tap to call",
|
||||
color: Colors.black.withOpacity(.4),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: Color(0xFFF95F62)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.email_sharp,
|
||||
color: Color(0xFFF95F62),
|
||||
size: 32.w,
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Email",
|
||||
color: Colors.black.withOpacity(.6),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w500,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: attraction.bookingEmail??"N/A",
|
||||
color: Colors.black,
|
||||
size: 14.sp,
|
||||
weight: FontWeight.w600,
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
CustomText(
|
||||
text: "Tap to email",
|
||||
color: Colors.black.withOpacity(.4),
|
||||
size: 12.sp,
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context)
|
||||
.pushNamed(RouteConstants.makeBooking);
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 24.w,
|
||||
vertical: 18.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFF95F62),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: "Via CityCards",
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: "Create a booking via app",
|
||||
size: 11.sp,
|
||||
weight: FontWeight.w400,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
"What is included",
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
|
||||
// Dynamic Inclusions from API
|
||||
Wrap(
|
||||
runSpacing: 16.h,
|
||||
spacing: 16.w,
|
||||
children: attraction.attractionInclusions
|
||||
.where((inclusion) => inclusion.isInclusion)
|
||||
.map(
|
||||
(inclusion) => includedBox(
|
||||
"assets/icons/bus.png",
|
||||
inclusion.title,
|
||||
inclusion.description,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
"Exact Location",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
CustomText(
|
||||
text: "View the location on map",
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(.6),
|
||||
),
|
||||
SizedBox(height: 17.h),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(13.54.r),
|
||||
child: Image.asset(
|
||||
height: 178.7.h,
|
||||
width: double.infinity,
|
||||
"assets/images/attra_detail_map.png",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 17.h),
|
||||
CustomText(
|
||||
text: attraction.address,
|
||||
size: 12.sp,
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
),
|
||||
SizedBox(height: 30.h),
|
||||
Divider(color: Colors.black.withOpacity(0.2)),
|
||||
SizedBox(height: 30.h),
|
||||
Text(
|
||||
"People frequently ask",
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
faqBox(
|
||||
"About this place",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
faqBox(
|
||||
"Term and condition",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
SizedBox(height: 15.h),
|
||||
faqBox(
|
||||
"Cancellation Policy",
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: Text("Something went wrong"),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget includedBox(String icon, String title, String disc) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
border: Border.all(color: Color(0xFFFDCDCE)),
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(icon, scale: 4),
|
||||
SizedBox(width: 16.w),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CustomText(
|
||||
text: title,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFF212121),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
CustomText(
|
||||
text: disc,
|
||||
size: 11.sp,
|
||||
weight: FontWeight.w400,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget faqBox(String title, String desc) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5F5),
|
||||
border: Border.all(color: Color(0xFFFDCDCE)),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomText(
|
||||
text: title,
|
||||
size: 16.sp,
|
||||
weight: FontWeight.w500,
|
||||
color: Color(0xFF212121),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 20.w),
|
||||
Icon(Icons.arrow_forward_ios_outlined, size: 18.sp),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 9.h),
|
||||
CustomText(text: desc, size: 11.sp, color: Color(0xFF7D7D7D)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,42 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../models/attraction_model.dart';
|
||||
import '../repository/attractions_repository.dart';
|
||||
|
||||
part 'attractions_event.dart';
|
||||
part 'attractions_state.dart';
|
||||
import 'attractions_event.dart';
|
||||
import 'attractions_state.dart';
|
||||
|
||||
class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
|
||||
final AttractionsRepository repository;
|
||||
|
||||
AttractionsBloc(this.repository) : super(AttractionsInitial()) {
|
||||
on<LoadAttractions>((event, emit) {
|
||||
final attractions = repository.fetchAttractions();
|
||||
emit(AttractionsLoaded(attractions));
|
||||
});
|
||||
|
||||
on<LoadMyPassAttraction>((event, emit) {
|
||||
final attractions = repository.fetchMyPassAttraction();
|
||||
emit(AttractionsLoaded(attractions));
|
||||
});
|
||||
|
||||
on<SearchAttractions>((event, emit) {
|
||||
if (state is AttractionsLoaded) {
|
||||
final currentState = state as AttractionsLoaded;
|
||||
final filtered = currentState.attractions
|
||||
.where((a) =>
|
||||
a.title.toLowerCase().contains(event.query.toLowerCase()) ||
|
||||
a.location.toLowerCase().contains(event.query.toLowerCase()))
|
||||
.toList();
|
||||
emit(AttractionsLoaded(filtered));
|
||||
}
|
||||
});
|
||||
AttractionsBloc({required this.repository})
|
||||
: super(AttractionsInitial()) {
|
||||
on<FetchAttractionsByCategory>(_onFetchAttractionsByCategory);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onFetchAttractionsByCategory(
|
||||
FetchAttractionsByCategory event,
|
||||
Emitter<AttractionsState> emit,
|
||||
) async {
|
||||
emit(AttractionsLoading());
|
||||
|
||||
try {
|
||||
final AttractionsResponse response =
|
||||
await repository.fetchAttractionsByCategory(
|
||||
categoryXid: event.categoryXid, // Can be null now
|
||||
);
|
||||
|
||||
emit(
|
||||
AttractionsLoaded(
|
||||
attractions: response.attractions ?? [],
|
||||
categories: response.categories ?? [],
|
||||
selectedCategoryId: event.categoryXid, // Can be null
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
AttractionsError(
|
||||
e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
part of 'attractions_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class AttractionsEvent {}
|
||||
abstract class AttractionsEvent extends Equatable {
|
||||
const AttractionsEvent();
|
||||
|
||||
class LoadAttractions extends AttractionsEvent {}
|
||||
|
||||
class LoadMyPassAttraction extends AttractionsEvent {}
|
||||
|
||||
class SearchAttractions extends AttractionsEvent {
|
||||
final String query;
|
||||
SearchAttractions(this.query);
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class FetchAttractionsByCategory extends AttractionsEvent {
|
||||
final int? categoryXid; // Make it nullable
|
||||
|
||||
const FetchAttractionsByCategory({this.categoryXid}); // Remove required
|
||||
|
||||
@override
|
||||
List<Object?> get props => [categoryXid];
|
||||
}
|
||||
@@ -1,10 +1,37 @@
|
||||
part of 'attractions_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../models/attraction_model.dart';
|
||||
|
||||
abstract class AttractionsState {}
|
||||
abstract class AttractionsState extends Equatable {
|
||||
const AttractionsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class AttractionsInitial extends AttractionsState {}
|
||||
|
||||
class AttractionsLoading extends AttractionsState {}
|
||||
|
||||
class AttractionsLoaded extends AttractionsState {
|
||||
final List<Attraction> attractions;
|
||||
AttractionsLoaded(this.attractions);
|
||||
final List<Category> categories;
|
||||
final int? selectedCategoryId; // Make it nullable
|
||||
|
||||
const AttractionsLoaded({
|
||||
required this.attractions,
|
||||
required this.categories,
|
||||
this.selectedCategoryId, // Remove required
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [attractions, categories, selectedCategoryId];
|
||||
}
|
||||
|
||||
class AttractionsError extends AttractionsState {
|
||||
final String message;
|
||||
|
||||
const AttractionsError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -1,19 +1,241 @@
|
||||
class AttractionsResponse {
|
||||
List<Attraction>? attractions;
|
||||
List<Category>? categories;
|
||||
|
||||
AttractionsResponse({this.attractions, this.categories});
|
||||
|
||||
AttractionsResponse.fromJson(Map<String, dynamic> json) {
|
||||
if (json['attractions'] != null) {
|
||||
attractions = <Attraction>[];
|
||||
json['attractions'].forEach((v) {
|
||||
attractions!.add(Attraction.fromJson(v));
|
||||
});
|
||||
}
|
||||
|
||||
if (json['categories'] != null) {
|
||||
categories = <Category>[];
|
||||
json['categories'].forEach((v) {
|
||||
categories!.add(Category.fromJson(v));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
if (attractions != null) {
|
||||
data['attractions'] = attractions!.map((v) => v.toJson()).toList();
|
||||
}
|
||||
if (categories != null) {
|
||||
data['categories'] = categories!.map((v) => v.toJson()).toList();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- ATTRACTION -------------------- */
|
||||
|
||||
class Attraction {
|
||||
final String title;
|
||||
final String location;
|
||||
final String price;
|
||||
final String image;
|
||||
final List<String> tags;
|
||||
final bool isBookingRequired;
|
||||
final String description;
|
||||
int? id;
|
||||
String? title;
|
||||
String? description;
|
||||
int? cityXid;
|
||||
int? cardTypeXid;
|
||||
int? partnerXid;
|
||||
String? productCode;
|
||||
String? subTitle;
|
||||
String? urlSlug;
|
||||
bool? isBookingRequired;
|
||||
bool? isPartnerAccess;
|
||||
String? bookingEmail;
|
||||
String? bookingPhoneNumber;
|
||||
String? address;
|
||||
double? latitudeCoordinate;
|
||||
double? longitudeCoordinate;
|
||||
int? ticketPriceAdult;
|
||||
int? ticketPriceChild;
|
||||
int? durations;
|
||||
int? groupSize;
|
||||
String? ageRange;
|
||||
String? seoTitle;
|
||||
String? seoDescription;
|
||||
String? attractionStatus;
|
||||
bool? isActive;
|
||||
String? createdAt;
|
||||
String? updatedAt;
|
||||
List<AttractionCategory>? attractionCategories;
|
||||
|
||||
Attraction({
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.price,
|
||||
required this.image,
|
||||
required this.tags,
|
||||
required this.isBookingRequired,
|
||||
required this.description
|
||||
this.id,
|
||||
this.title,
|
||||
this.description,
|
||||
this.cityXid,
|
||||
this.cardTypeXid,
|
||||
this.partnerXid,
|
||||
this.productCode,
|
||||
this.subTitle,
|
||||
this.urlSlug,
|
||||
this.isBookingRequired,
|
||||
this.isPartnerAccess,
|
||||
this.bookingEmail,
|
||||
this.bookingPhoneNumber,
|
||||
this.address,
|
||||
this.latitudeCoordinate,
|
||||
this.longitudeCoordinate,
|
||||
this.ticketPriceAdult,
|
||||
this.ticketPriceChild,
|
||||
this.durations,
|
||||
this.groupSize,
|
||||
this.ageRange,
|
||||
this.seoTitle,
|
||||
this.seoDescription,
|
||||
this.attractionStatus,
|
||||
this.isActive,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.attractionCategories,
|
||||
});
|
||||
|
||||
Attraction.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
title = json['title'];
|
||||
description = json['description'];
|
||||
cityXid = json['cityXid'];
|
||||
cardTypeXid = json['cardTypeXid'];
|
||||
partnerXid = json['partnerXid'];
|
||||
productCode = json['productCode'];
|
||||
subTitle = json['subTitle'];
|
||||
urlSlug = json['urlSlug'];
|
||||
isBookingRequired = json['isBookingRequired'];
|
||||
isPartnerAccess = json['isPartnerAccess'];
|
||||
bookingEmail = json['bookingEmail'];
|
||||
bookingPhoneNumber = json['bookingPhoneNumber'];
|
||||
address = json['address'];
|
||||
latitudeCoordinate =
|
||||
json['latitudeCoordinate']?.toDouble();
|
||||
longitudeCoordinate =
|
||||
json['longitudeCoordinate']?.toDouble();
|
||||
ticketPriceAdult = json['ticketPriceAdult'];
|
||||
ticketPriceChild = json['ticketPriceChild'];
|
||||
durations = json['durations'];
|
||||
groupSize = json['groupSize'];
|
||||
ageRange = json['ageRange'];
|
||||
seoTitle = json['seoTitle'];
|
||||
seoDescription = json['seoDescription'];
|
||||
attractionStatus = json['attractionStatus'];
|
||||
isActive = json['isActive'];
|
||||
createdAt = json['createdAt'];
|
||||
updatedAt = json['updatedAt'];
|
||||
|
||||
if (json['attractionCategories'] != null) {
|
||||
attractionCategories = <AttractionCategory>[];
|
||||
json['attractionCategories'].forEach((v) {
|
||||
attractionCategories!.add(AttractionCategory.fromJson(v));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
data['id'] = id;
|
||||
data['title'] = title;
|
||||
data['description'] = description;
|
||||
data['cityXid'] = cityXid;
|
||||
data['cardTypeXid'] = cardTypeXid;
|
||||
data['partnerXid'] = partnerXid;
|
||||
data['productCode'] = productCode;
|
||||
data['subTitle'] = subTitle;
|
||||
data['urlSlug'] = urlSlug;
|
||||
data['isBookingRequired'] = isBookingRequired;
|
||||
data['isPartnerAccess'] = isPartnerAccess;
|
||||
data['bookingEmail'] = bookingEmail;
|
||||
data['bookingPhoneNumber'] = bookingPhoneNumber;
|
||||
data['address'] = address;
|
||||
data['latitudeCoordinate'] = latitudeCoordinate;
|
||||
data['longitudeCoordinate'] = longitudeCoordinate;
|
||||
data['ticketPriceAdult'] = ticketPriceAdult;
|
||||
data['ticketPriceChild'] = ticketPriceChild;
|
||||
data['durations'] = durations;
|
||||
data['groupSize'] = groupSize;
|
||||
data['ageRange'] = ageRange;
|
||||
data['seoTitle'] = seoTitle;
|
||||
data['seoDescription'] = seoDescription;
|
||||
data['attractionStatus'] = attractionStatus;
|
||||
data['isActive'] = isActive;
|
||||
data['createdAt'] = createdAt;
|
||||
data['updatedAt'] = updatedAt;
|
||||
|
||||
if (attractionCategories != null) {
|
||||
data['attractionCategories'] =
|
||||
attractionCategories!.map((v) => v.toJson()).toList();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- ATTRACTION CATEGORY -------------------- */
|
||||
|
||||
class AttractionCategory {
|
||||
int? id;
|
||||
int? attractionXid;
|
||||
int? categoryXid;
|
||||
bool? isActive;
|
||||
String? createdAt;
|
||||
String? updatedAt;
|
||||
Category? category;
|
||||
|
||||
AttractionCategory({
|
||||
this.id,
|
||||
this.attractionXid,
|
||||
this.categoryXid,
|
||||
this.isActive,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.category,
|
||||
});
|
||||
|
||||
AttractionCategory.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
attractionXid = json['attractionXid'];
|
||||
categoryXid = json['categoryXid'];
|
||||
isActive = json['isActive'];
|
||||
createdAt = json['createdAt'];
|
||||
updatedAt = json['updatedAt'];
|
||||
category =
|
||||
json['category'] != null ? Category.fromJson(json['category']) : null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
data['id'] = id;
|
||||
data['attractionXid'] = attractionXid;
|
||||
data['categoryXid'] = categoryXid;
|
||||
data['isActive'] = isActive;
|
||||
data['createdAt'] = createdAt;
|
||||
data['updatedAt'] = updatedAt;
|
||||
if (category != null) {
|
||||
data['category'] = category!.toJson();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- CATEGORY -------------------- */
|
||||
|
||||
class Category {
|
||||
int? id;
|
||||
String? categoryName;
|
||||
|
||||
Category({this.id, this.categoryName});
|
||||
|
||||
Category.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
categoryName = json['categoryName'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
data['id'] = id;
|
||||
data['categoryName'] = categoryName;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +1,26 @@
|
||||
import 'package:citycards_customer/common_packages/common_app_texts.dart';
|
||||
|
||||
import 'package:citycards_customer/networkApiServices/api_urls.dart';
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../models/attraction_model.dart';
|
||||
|
||||
class AttractionsRepository {
|
||||
List<Attraction> fetchAttractions() {
|
||||
return [
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_1.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Siem Reap",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_2.jpg",
|
||||
tags: ["Unlimited Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_3.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_4.jpg",
|
||||
tags: ["${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_5.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: false,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
];
|
||||
}
|
||||
final NetworkApiService _apiServices = NetworkApiService();
|
||||
|
||||
List<Attraction> fetchMyPassAttraction() {
|
||||
return [
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_1.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Siem Reap",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_2.jpg",
|
||||
tags: ["Unlimited Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_3.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Koh Rong Samloem",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_4.jpg",
|
||||
tags: ["${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
Attraction(
|
||||
title: "Dart Palace",
|
||||
location: "Krong Siem Reap",
|
||||
price: "\$25",
|
||||
image: "assets/dummy/dummy_5.jpg",
|
||||
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
|
||||
isBookingRequired: true,
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
|
||||
),
|
||||
];
|
||||
/// Fetch attractions by categoryXid (optional)
|
||||
Future<AttractionsResponse> fetchAttractionsByCategory({
|
||||
int? categoryXid, // Make it nullable
|
||||
}) async {
|
||||
try {
|
||||
// Build URL with or without categoryXid
|
||||
String url = ApiUrls.attractionsList;
|
||||
if (categoryXid != null) {
|
||||
url = '$url?categoryXid=$categoryXid';
|
||||
}
|
||||
|
||||
final response = await _apiServices.getApi(url: url);
|
||||
|
||||
return AttractionsResponse.fromJson(response.data);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch attractions: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import 'package:citycards_customer/common_packages/back_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../common_packages/custom_search_field.dart';
|
||||
import '../blocs/attractions_bloc.dart';
|
||||
import '../blocs/attractions_event.dart';
|
||||
import '../blocs/attractions_state.dart';
|
||||
import '../repository/attractions_repository.dart';
|
||||
import '../widget/attraction_card.dart';
|
||||
import '../widget/filter_chip.dart';
|
||||
@@ -17,14 +20,13 @@ class AttractionsPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) {
|
||||
final bloc = AttractionsBloc(AttractionsRepository());
|
||||
final bloc = AttractionsBloc(
|
||||
repository: AttractionsRepository(),
|
||||
);
|
||||
|
||||
// 🔥 Trigger event based on source
|
||||
if (source == "home") {
|
||||
bloc.add(LoadAttractions());
|
||||
} else if (source == "qrPass") {
|
||||
bloc.add(LoadMyPassAttraction());
|
||||
}
|
||||
bloc.add(
|
||||
const FetchAttractionsByCategory(), // No categoryXid parameter
|
||||
);
|
||||
|
||||
return bloc;
|
||||
},
|
||||
@@ -41,42 +43,73 @@ class AttractionsPage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// App bar
|
||||
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true),
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showDivider: true,
|
||||
),
|
||||
backWidget(context, "Your Attraction", Colors.black),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 🔍 Search field
|
||||
// 🔍 Search field (UI kept, logic disabled)
|
||||
CommonSearchField(
|
||||
hint: "Search attractions...",
|
||||
hintColor: Colors.grey.shade500,
|
||||
onChanged: (value) {
|
||||
if (value.isEmpty) {
|
||||
bloc.add(LoadAttractions());
|
||||
} else {
|
||||
bloc.add(SearchAttractions(value));
|
||||
}
|
||||
// ❌ Search logic intentionally disabled
|
||||
// UI only, no API call
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🏝️ Category chips row
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
buildCategoryChip("Beach"),
|
||||
buildCategoryChip("Hike"),
|
||||
buildCategoryChip("Popular"),
|
||||
buildCategoryChip("Best in Summer"),
|
||||
],
|
||||
// 🏖️ Category chips row - DYNAMIC
|
||||
if (state is AttractionsLoaded)
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: state.categories
|
||||
.map(
|
||||
(category) => buildCategoryChip(
|
||||
category.categoryName ?? '',
|
||||
isSelected: state.selectedCategoryId == category.id,
|
||||
onTap: () {
|
||||
bloc.add(
|
||||
FetchAttractionsByCategory(
|
||||
categoryXid: category.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// else
|
||||
// // Show placeholder chips while loading
|
||||
// SingleChildScrollView(
|
||||
// scrollDirection: Axis.horizontal,
|
||||
// child: Row(
|
||||
// children: [
|
||||
// buildCategoryChip("Beach", isSelected: true, onTap: () {}),
|
||||
// buildCategoryChip("Hike", isSelected: false, onTap: () {}),
|
||||
// buildCategoryChip("Adventure", isSelected: false, onTap: () {}),
|
||||
// buildCategoryChip("Best in Summer", isSelected: false, onTap: () {}),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 🏙️ Attraction list
|
||||
if (state is AttractionsLoaded)
|
||||
// 🙏️ Attraction list
|
||||
if (state is AttractionsLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (state is AttractionsLoaded)
|
||||
state.attractions.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
@@ -84,7 +117,7 @@ class AttractionsPage extends StatelessWidget {
|
||||
child: Text(
|
||||
"No attractions found",
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
color: Colors.grey,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
@@ -92,17 +125,28 @@ class AttractionsPage extends StatelessWidget {
|
||||
)
|
||||
: Column(
|
||||
children: state.attractions
|
||||
.map((attraction) => AttractionCard(
|
||||
attraction: attraction))
|
||||
.map(
|
||||
(attraction) => AttractionCard(
|
||||
attraction: attraction,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
else
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
else if (state is AttractionsError)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Text(
|
||||
state.message,
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -112,4 +156,4 @@ class AttractionsPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import '../../core/route_constants.dart';
|
||||
@@ -10,64 +11,97 @@ class AttractionCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tags = attraction.attractionCategories
|
||||
?.map((e) => e.category?.categoryName ?? '')
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
return InkWell(
|
||||
onTap: (){
|
||||
Navigator.of(context).pushNamed(RouteConstants.attractionDetails);
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionDetails,
|
||||
arguments: attraction,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: EdgeInsets.symmetric(vertical: 8.h, horizontal: 8.w),
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xffFDCDCE)),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
color: Color(0xffFFF5F5),
|
||||
borderRadius: BorderRadius.circular(15.r),
|
||||
color: const Color(0xffFFF5F5),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
/// Image with fallback placeholder icon
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: Image.asset(
|
||||
attraction.image,
|
||||
height: 94,
|
||||
width: 94,
|
||||
'assets/images/attraction_placeholder.png',
|
||||
height: 94.h,
|
||||
width: 94.w,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 94.h,
|
||||
width: 94.w,
|
||||
color: Colors.grey.shade200,
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
size: 28.sp,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
SizedBox(width: 10.w),
|
||||
|
||||
/// Content
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
attraction.title,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
attraction.location,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xff464646),
|
||||
attraction.title ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
Text(
|
||||
attraction.address ?? '',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xff464646),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "from ${attraction.price}",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
text:
|
||||
"from \$${attraction.ticketPriceAdult ?? 0}",
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
TextSpan(
|
||||
text: "/person",
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontSize: 10.sp,
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
@@ -75,63 +109,69 @@ class AttractionCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
SizedBox(height: 6.h),
|
||||
|
||||
attraction.isBookingRequired == false
|
||||
? Wrap(
|
||||
spacing: 6,
|
||||
children: attraction.tags
|
||||
.map(
|
||||
(tag) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: tag == "${CommonAppText.selectiveCard} Card"
|
||||
? const Color(0xffF95FAF).withOpacity(0.1)
|
||||
: const Color(
|
||||
0xffF95F62,
|
||||
).withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: tag == "${CommonAppText.selectiveCard} Card"
|
||||
? const Color(0xffF95FAF)
|
||||
: const Color(0xffF95F62),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11,
|
||||
color: Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
spacing: 6.w,
|
||||
runSpacing: 6.h,
|
||||
children: tags
|
||||
.map(
|
||||
(tag) => Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 10.w,
|
||||
vertical: 4.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: tag ==
|
||||
"${CommonAppText.selectiveCard} Card"
|
||||
? const Color(0xffF95FAF)
|
||||
.withOpacity(0.1)
|
||||
: const Color(0xffF95F62)
|
||||
.withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: tag ==
|
||||
"${CommonAppText.selectiveCard} Card"
|
||||
? const Color(0xffF95FAF)
|
||||
: const Color(0xffF95F62),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xffC1D2F8),
|
||||
border: Border.all(
|
||||
color: Color(0xff2563EB),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
"Booking Required",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11,
|
||||
color: Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11.sp,
|
||||
color: const Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 10.w,
|
||||
vertical: 4.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffC1D2F8),
|
||||
border: Border.all(
|
||||
color: const Color(0xff2563EB),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
child: Text(
|
||||
"Booking Required",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11.sp,
|
||||
color: const Color(0xff1A1A1A),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import "package:flutter/material.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Widget buildCategoryChip(String label) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xffF95F62),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
Widget buildCategoryChip(
|
||||
String label, {
|
||||
required bool isSelected,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
const Color redColor = Color(0xffF95F62);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? redColor : redColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(
|
||||
color: redColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : redColor,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:citycards_customer/Profile/profile_page_view.dart';
|
||||
import 'package:citycards_customer/add_details/add_details_view.dart';
|
||||
import 'package:citycards_customer/attraction_details/attraction_details_view.dart';
|
||||
import 'package:citycards_customer/attraction_details/views/attraction_details_view.dart';
|
||||
import 'package:citycards_customer/attractions/models/attraction_model.dart';
|
||||
import 'package:citycards_customer/buy_a_pass/view/buy_pass_view.dart';
|
||||
import 'package:citycards_customer/checkout/view/checkout_view.dart';
|
||||
import 'package:citycards_customer/common_bloc/language_selection_bloc.dart';
|
||||
@@ -146,9 +147,10 @@ class AppRouter {
|
||||
);
|
||||
|
||||
case RouteConstants.attractionDetails:
|
||||
final attractionId = settings.arguments as Attraction;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AttractionDetailsView();
|
||||
return AttractionDetailsView(attractionId: attractionId.id,);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:citycards_customer/attractions/models/attraction_model.dart';
|
||||
import 'package:citycards_customer/core/route_constants.dart';
|
||||
import 'package:citycards_customer/home/views/registered_user_home_page.dart';
|
||||
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
|
||||
@@ -5,7 +6,7 @@ import 'package:citycards_customer/postcard/views/add_filter_step_page_view.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../attraction_details/attraction_details_view.dart';
|
||||
import '../attraction_details/views/attraction_details_view.dart';
|
||||
import '../attractions/views/attractions_page_view.dart';
|
||||
import '../buy_a_pass/view/buy_pass_view.dart';
|
||||
import '../checkout/view/checkout_view.dart';
|
||||
@@ -53,9 +54,10 @@ Widget buildOffstageNavigator(
|
||||
);
|
||||
|
||||
case RouteConstants.attractionDetails:
|
||||
final attraction = settings.arguments as Attraction;
|
||||
return MaterialPageRoute(
|
||||
builder: (_) {
|
||||
return AttractionDetailsView();
|
||||
return AttractionDetailsView(attractionId: attraction.id);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
25
lib/home/bloc/registeredHome/home_bloc.dart
Normal file
25
lib/home/bloc/registeredHome/home_bloc.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repository/home_repository.dart';
|
||||
import 'home_event.dart';
|
||||
import 'home_state.dart';
|
||||
|
||||
class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
final HomeRepository homeRepository;
|
||||
|
||||
HomeBloc({required this.homeRepository}) : super(HomeInitial()) {
|
||||
on<FetchHomeData>(_onFetchHomeData);
|
||||
}
|
||||
|
||||
Future<void> _onFetchHomeData(
|
||||
FetchHomeData event,
|
||||
Emitter<HomeState> emit,
|
||||
) async {
|
||||
emit(HomeLoading());
|
||||
try {
|
||||
final homeModel = await homeRepository.fetchHomeData();
|
||||
emit(HomeLoaded(homeModel));
|
||||
} catch (e) {
|
||||
emit(HomeError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
3
lib/home/bloc/registeredHome/home_event.dart
Normal file
3
lib/home/bloc/registeredHome/home_event.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
abstract class HomeEvent {}
|
||||
|
||||
class FetchHomeData extends HomeEvent {}
|
||||
19
lib/home/bloc/registeredHome/home_state.dart
Normal file
19
lib/home/bloc/registeredHome/home_state.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import '../../model/home_model.dart';
|
||||
|
||||
abstract class HomeState {}
|
||||
|
||||
class HomeInitial extends HomeState {}
|
||||
|
||||
class HomeLoading extends HomeState {}
|
||||
|
||||
class HomeLoaded extends HomeState {
|
||||
final HomeModel homeModel;
|
||||
|
||||
HomeLoaded(this.homeModel);
|
||||
}
|
||||
|
||||
class HomeError extends HomeState {
|
||||
final String message;
|
||||
|
||||
HomeError(this.message);
|
||||
}
|
||||
300
lib/home/model/home_model.dart
Normal file
300
lib/home/model/home_model.dart
Normal file
@@ -0,0 +1,300 @@
|
||||
class HomeModel {
|
||||
final City? city;
|
||||
final List<Attraction>? attraction;
|
||||
|
||||
HomeModel({
|
||||
this.city,
|
||||
this.attraction,
|
||||
});
|
||||
|
||||
factory HomeModel.fromJson(Map<String, dynamic> json) {
|
||||
return HomeModel(
|
||||
city: json['city'] != null ? City.fromJson(json['city']) : null,
|
||||
attraction: json['attraction'] != null
|
||||
? List<Attraction>.from(
|
||||
json['attraction'].map((x) => Attraction.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class City {
|
||||
final int? id;
|
||||
final String? cityName;
|
||||
final String? urlSlug;
|
||||
final String? tagLine;
|
||||
final String? description;
|
||||
final String? metaTitle;
|
||||
final String? metaDescription;
|
||||
final String? bestTimeToVisit;
|
||||
final String? priceRange;
|
||||
final int? indivisualTicketAmt;
|
||||
final int? cityCardTicketAmt;
|
||||
final String? seoTitle;
|
||||
final String? seoDescription;
|
||||
final int? displayOrder;
|
||||
final bool? isActive;
|
||||
final String? createdAt;
|
||||
final String? updatedAt;
|
||||
final List<CityBanner>? cityBanners;
|
||||
final List<CardModel>? cards;
|
||||
final List<CityFeatureCard>? cityFeatureCards;
|
||||
final List<dynamic>? cityHighlights;
|
||||
|
||||
City({
|
||||
this.id,
|
||||
this.cityName,
|
||||
this.urlSlug,
|
||||
this.tagLine,
|
||||
this.description,
|
||||
this.metaTitle,
|
||||
this.metaDescription,
|
||||
this.bestTimeToVisit,
|
||||
this.priceRange,
|
||||
this.indivisualTicketAmt,
|
||||
this.cityCardTicketAmt,
|
||||
this.seoTitle,
|
||||
this.seoDescription,
|
||||
this.displayOrder,
|
||||
this.isActive,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.cityBanners,
|
||||
this.cards,
|
||||
this.cityFeatureCards,
|
||||
this.cityHighlights,
|
||||
});
|
||||
|
||||
factory City.fromJson(Map<String, dynamic> json) {
|
||||
return City(
|
||||
id: json['id'],
|
||||
cityName: json['cityName'],
|
||||
urlSlug: json['urlSlug'],
|
||||
tagLine: json['tagLine'],
|
||||
description: json['description'],
|
||||
metaTitle: json['metaTitle'],
|
||||
metaDescription: json['metaDescription'],
|
||||
bestTimeToVisit: json['bestTimeToVisit'],
|
||||
priceRange: json['priceRange'],
|
||||
indivisualTicketAmt: json['indivisualTicketAmt'],
|
||||
cityCardTicketAmt: json['cityCardTicketAmt'],
|
||||
seoTitle: json['seoTitle'],
|
||||
seoDescription: json['seoDescription'],
|
||||
displayOrder: json['displayOrder'],
|
||||
isActive: json['isActive'],
|
||||
createdAt: json['createdAt'],
|
||||
updatedAt: json['updatedAt'],
|
||||
cityBanners: json['cityBanners'] != null
|
||||
? List<CityBanner>.from(
|
||||
json['cityBanners'].map((x) => CityBanner.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
cards: json['cards'] != null
|
||||
? List<CardModel>.from(
|
||||
json['cards'].map((x) => CardModel.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
cityFeatureCards: json['cityFeatureCards'] != null
|
||||
? List<CityFeatureCard>.from(
|
||||
json['cityFeatureCards']
|
||||
.map((x) => CityFeatureCard.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
cityHighlights: json['cityHighlights'] ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CardModel {
|
||||
final int? id;
|
||||
final int? cityXid;
|
||||
final String? title;
|
||||
final String? description;
|
||||
final int? cardTypeXid;
|
||||
final int? minNumber;
|
||||
final int? maxNumber;
|
||||
final int? validityDuration;
|
||||
final bool? isMultiplyEntry;
|
||||
final int? adultPrice;
|
||||
final int? childPrice;
|
||||
final String? cardStatus;
|
||||
final bool? isActive;
|
||||
final String? createdAt;
|
||||
final String? updatedAt;
|
||||
|
||||
CardModel({
|
||||
this.id,
|
||||
this.cityXid,
|
||||
this.title,
|
||||
this.description,
|
||||
this.cardTypeXid,
|
||||
this.minNumber,
|
||||
this.maxNumber,
|
||||
this.validityDuration,
|
||||
this.isMultiplyEntry,
|
||||
this.adultPrice,
|
||||
this.childPrice,
|
||||
this.cardStatus,
|
||||
this.isActive,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory CardModel.fromJson(Map<String, dynamic> json) {
|
||||
return CardModel(
|
||||
id: json['id'],
|
||||
cityXid: json['cityXid'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
cardTypeXid: json['cardTypeXid'],
|
||||
minNumber: json['minNumber'],
|
||||
maxNumber: json['maxNumber'],
|
||||
validityDuration: json['validityDuration'],
|
||||
isMultiplyEntry: json['isMultiplyEntry'],
|
||||
adultPrice: json['adultPrice'],
|
||||
childPrice: json['childPrice'],
|
||||
cardStatus: json['cardStatus'],
|
||||
isActive: json['isActive'],
|
||||
createdAt: json['createdAt'],
|
||||
updatedAt: json['updatedAt'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CityBanner {
|
||||
final int? id;
|
||||
final int? cityXid;
|
||||
final String? title;
|
||||
final String? highlightWord;
|
||||
final String? description;
|
||||
final String? imageFilePath;
|
||||
final String? ctaLabel;
|
||||
final String? ctaUrl;
|
||||
final bool? isActive;
|
||||
final String? createdAt;
|
||||
final String? updatedAt;
|
||||
|
||||
CityBanner({
|
||||
this.id,
|
||||
this.cityXid,
|
||||
this.title,
|
||||
this.highlightWord,
|
||||
this.description,
|
||||
this.imageFilePath,
|
||||
this.ctaLabel,
|
||||
this.ctaUrl,
|
||||
this.isActive,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory CityBanner.fromJson(Map<String, dynamic> json) {
|
||||
return CityBanner(
|
||||
id: json['id'],
|
||||
cityXid: json['cityXid'],
|
||||
title: json['title'],
|
||||
highlightWord: json['highlightWord'],
|
||||
description: json['description'],
|
||||
imageFilePath: json['imageFilePath'],
|
||||
ctaLabel: json['ctaLabel'],
|
||||
ctaUrl: json['ctaUrl'],
|
||||
isActive: json['isActive'],
|
||||
createdAt: json['createdAt'],
|
||||
updatedAt: json['updatedAt'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CityFeatureCard {
|
||||
final int? id;
|
||||
final String? title;
|
||||
final String? description;
|
||||
final String? icon;
|
||||
|
||||
CityFeatureCard({
|
||||
this.id,
|
||||
this.title,
|
||||
this.description,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
factory CityFeatureCard.fromJson(Map<String, dynamic> json) {
|
||||
return CityFeatureCard(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
icon: json['icon'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Attraction {
|
||||
final int? id;
|
||||
final String? title;
|
||||
final String? description;
|
||||
final String? urlSlug;
|
||||
final List<AttractionGallery>? attractionGalleries;
|
||||
|
||||
Attraction({
|
||||
this.id,
|
||||
this.title,
|
||||
this.description,
|
||||
this.urlSlug,
|
||||
this.attractionGalleries,
|
||||
});
|
||||
|
||||
factory Attraction.fromJson(Map<String, dynamic> json) {
|
||||
return Attraction(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
urlSlug: json['urlSlug'],
|
||||
attractionGalleries: json['attractionGalleries'] != null
|
||||
? List<AttractionGallery>.from(
|
||||
json['attractionGalleries']
|
||||
.map((x) => AttractionGallery.fromJson(x)),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
this.id,
|
||||
this.attractionXid,
|
||||
this.fileType,
|
||||
this.filePathUrl,
|
||||
this.altText,
|
||||
this.isCoverImage,
|
||||
this.isActive,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory AttractionGallery.fromJson(Map<String, dynamic> json) {
|
||||
return AttractionGallery(
|
||||
id: json['id'],
|
||||
attractionXid: json['attractionXid'],
|
||||
fileType: json['fileType'],
|
||||
filePathUrl: json['filePathUrl'],
|
||||
altText: json['altText'],
|
||||
isCoverImage: json['isCoverImage'],
|
||||
isActive: json['isActive'],
|
||||
createdAt: json['createdAt'],
|
||||
updatedAt: json['updatedAt'],
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/home/repository/home_repository.dart
Normal file
18
lib/home/repository/home_repository.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import '../../networkApiServices/network_api_services.dart';
|
||||
import '../../networkApiServices/api_urls.dart';
|
||||
import '../model/home_model.dart';
|
||||
|
||||
class HomeRepository {
|
||||
final NetworkApiService _apiService = NetworkApiService();
|
||||
|
||||
Future<HomeModel> fetchHomeData() async {
|
||||
const int cityId = 1;
|
||||
|
||||
final response = await _apiService.getApi(
|
||||
url: '${ApiUrls.home}/$cityId',
|
||||
);
|
||||
|
||||
return HomeModel.fromJson(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../common_bloc/bottom_navigation_bloc.dart';
|
||||
import '../../common_packages/app_bar.dart';
|
||||
import '../../core/route_constants.dart';
|
||||
import '../bloc/registeredHome/home_bloc.dart';
|
||||
import '../bloc/registeredHome/home_event.dart';
|
||||
import '../bloc/registeredHome/home_state.dart';
|
||||
import '../widgets/attractions_list.dart';
|
||||
import '../widgets/get_your_pass_card.dart';
|
||||
import '../widgets/gradient_container_bg.dart';
|
||||
@@ -22,223 +24,246 @@ class RegisteredUserHomePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
|
||||
final List<Map<String, String>> attractions = [
|
||||
{
|
||||
'title': 'Koh Rong Samloemr',
|
||||
'subtitle': 'Lorem ipsum dolor sit amet...',
|
||||
'image': 'assets/images/koh_rong.png',
|
||||
},
|
||||
{
|
||||
'title': 'Long-Tail Boat Charter',
|
||||
'subtitle': 'Lorem ipsum dolor sit amet...',
|
||||
'image': 'assets/images/clock.png',
|
||||
},
|
||||
{
|
||||
'title': 'Koh Rong Samloemr',
|
||||
'subtitle': 'Lorem ipsum dolor sit amet...',
|
||||
'image': 'assets/images/koh_rong.png',
|
||||
},
|
||||
{
|
||||
'title': 'Long-Tail Boat Charter',
|
||||
'subtitle': 'Lorem ipsum dolor sit amet...',
|
||||
'image': 'assets/images/clock.png',
|
||||
},
|
||||
];
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<HomeBloc>().add(FetchHomeData());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.asset(
|
||||
"assets/images/chicago.png",
|
||||
height: 300.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
child: BlocBuilder<HomeBloc, HomeState>(
|
||||
builder: (context, state) {
|
||||
if (state is HomeLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
if (state is HomeError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Error: ${state.message}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<HomeBloc>().add(FetchHomeData());
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is HomeLoaded) {
|
||||
final city = state.homeModel.city;
|
||||
final attractions = state.homeModel.attraction ?? [];
|
||||
final bannerImageUrl = city?.cityBanners?.isNotEmpty == true
|
||||
? city!.cityBanners!.firstWhere(
|
||||
(banner) => banner.isActive == true && banner.imageFilePath != null,
|
||||
orElse: () => city.cityBanners!.first,
|
||||
).imageFilePath
|
||||
: null;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background image - use city banner if available
|
||||
_buildBannerImage(bannerImageUrl),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showDivider: false,
|
||||
),
|
||||
SizedBox(height: 60.h),
|
||||
Text(
|
||||
"Melbourne",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 44,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
|
||||
"Cras posuere, nisl id dictum consequat, elit enim tincidunt magna...",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
// Category tags
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
_buildTag("Food"),
|
||||
_buildTag("Drinks"),
|
||||
_buildTag("Culture"),
|
||||
_buildTag("Souvenirs"),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 60.h),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: const [
|
||||
TextSpan(
|
||||
text: "Popular ",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: "Attractions",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CommonAppBar(
|
||||
isWhiteLogo: false,
|
||||
isProfilePage: false,
|
||||
showDivider: false,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionsPage,
|
||||
arguments: "home",
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
"View all",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
SizedBox(height: 60.h),
|
||||
|
||||
// City name from API
|
||||
Text(
|
||||
city?.cityName ?? "City Name",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xffF95F62),
|
||||
fontSize: 44,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AttractionsListView(attractions: attractions),
|
||||
],
|
||||
),
|
||||
),
|
||||
InwardCurvedContainer(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 40.h),
|
||||
const ItineraryVideo(),
|
||||
SizedBox(height: 20.h),
|
||||
SizedBox(height: 4.h),
|
||||
|
||||
// 🔘 Button section
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<NavigationBloc>().add(NavigationTabChanged(1));
|
||||
// Navigator.of(
|
||||
// context,
|
||||
// ).pushNamed(RouteConstants.buyPass);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xffF95F62),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
// City description from API
|
||||
Text(
|
||||
city?.description ?? "City description",
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
// Category tags - you can customize this based on your needs
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
"Create my iternary",
|
||||
style: GoogleFonts.poppins(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward, color: Colors.white),
|
||||
_buildTag("Food"),
|
||||
_buildTag("Drinks"),
|
||||
_buildTag("Culture"),
|
||||
_buildTag("Souvenirs"),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 60.h),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: const [
|
||||
TextSpan(
|
||||
text: "Popular ",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: "Attractions",
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteConstants.attractionsPage,
|
||||
arguments: "home",
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
"View all",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xffF95F62),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Pass attractions from API
|
||||
AttractionsListView(attractions: attractions),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
InwardCurvedContainer(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 40.h),
|
||||
const ItineraryVideo(),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// Button section
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<NavigationBloc>().add(NavigationTabChanged(1));
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xffF95F62),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Create my itinerary",
|
||||
style: GoogleFonts.poppins(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.arrow_forward, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
ESimOfferSection(),
|
||||
HotelOffersSection(),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pushNamed(RouteConstants.searchOffer);
|
||||
},
|
||||
child: _buildFeatureCard(
|
||||
image: "assets/images/claim_offers_bg.jpg",
|
||||
title: "Claim offers with your City Cards",
|
||||
subtitle: "Lorem ipsum dolor sit amet...",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
ChooseYourPassSection(
|
||||
cards: state.homeModel.city?.cards ?? [],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GetYourPassCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ESimOfferSection(),
|
||||
HotelOffersSection(),
|
||||
const SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteConstants.searchOffer);
|
||||
},
|
||||
child: _buildFeatureCard(
|
||||
image: "assets/images/claim_offers_bg.jpg",
|
||||
title: "Claim offers with your City Cards",
|
||||
subtitle: "Lorem ipsum dolor sit amet...",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
ChooseYourPassSection(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
GetYourPassCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Initial state
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -247,12 +272,12 @@ class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xffFFFFFF).withOpacity(0.29),
|
||||
color: const Color(0xffFFFFFF).withOpacity(0.29),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
@@ -315,10 +340,7 @@ class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Right side arrow button
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xffFDCDCE),
|
||||
@@ -337,4 +359,47 @@ class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Widget _buildBannerImage(String? imageUrl) {
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
// Use placeholder if no image URL
|
||||
return Image.asset(
|
||||
"assets/images/chicago.png",
|
||||
height: 300.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
return Image.network(
|
||||
imageUrl,
|
||||
height: 300.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
height: 300.h,
|
||||
width: double.infinity,
|
||||
color: Colors.grey[300],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Use placeholder on error
|
||||
return Image.asset(
|
||||
"assets/images/chicago.png",
|
||||
height: 300.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../attraction_details/views/attraction_details_view.dart';
|
||||
import '../../core/route_constants.dart';
|
||||
import '../model/home_model.dart';
|
||||
|
||||
class AttractionsListView extends StatefulWidget {
|
||||
final List<Map<String, String>> attractions;
|
||||
final List<Attraction> attractions;
|
||||
|
||||
const AttractionsListView({super.key, required this.attractions});
|
||||
|
||||
@@ -37,24 +39,60 @@ class _AttractionsListViewState extends State<AttractionsListView> {
|
||||
});
|
||||
}
|
||||
|
||||
// Get cover image from attraction galleries
|
||||
String? _getCoverImage(Attraction attraction) {
|
||||
if (attraction.attractionGalleries == null ||
|
||||
attraction.attractionGalleries!.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to find the cover image
|
||||
final coverImage = attraction.attractionGalleries!.firstWhere(
|
||||
(gallery) => gallery.isCoverImage == true,
|
||||
orElse: () => attraction.attractionGalleries!.first,
|
||||
);
|
||||
|
||||
return coverImage.filePathUrl;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: (){
|
||||
Navigator.of(context).pushNamed(RouteConstants.attractionDetails);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
itemCount: widget.attractions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.attractions[index];
|
||||
return Container(
|
||||
// Show placeholder if no attractions
|
||||
if (widget.attractions.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
'No attractions available',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
itemCount: widget.attractions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final attraction = widget.attractions[index];
|
||||
final imageUrl = _getCoverImage(attraction);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttractionDetailsView(attractionId: attraction.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
padding: const EdgeInsets.all(4),
|
||||
@@ -69,44 +107,112 @@ class _AttractionsListViewState extends State<AttractionsListView> {
|
||||
width: 161,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
image: DecorationImage(
|
||||
image: AssetImage(item['image']!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
item['title']!,
|
||||
style: GoogleFonts.poppins(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Image or placeholder
|
||||
if (imageUrl != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
height: 232,
|
||||
width: 161,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _buildPlaceholder();
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildPlaceholder(),
|
||||
|
||||
// Title overlay
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
attraction.title ?? 'Untitled',
|
||||
style: GoogleFonts.poppins(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: LinearProgressIndicator(
|
||||
value: _scrollProgress,
|
||||
minHeight: 6,
|
||||
backgroundColor: const Color(0xffFEE7E7),
|
||||
color: const Color(0xffF95F62),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: LinearProgressIndicator(
|
||||
value: _scrollProgress,
|
||||
minHeight: 6,
|
||||
backgroundColor: const Color(0xffFEE7E7),
|
||||
color: const Color(0xffF95F62),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.image_outlined,
|
||||
size: 50,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,15 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../common_packages/common_app_texts.dart';
|
||||
import '../../core/route_constants.dart';
|
||||
import '../model/home_model.dart';
|
||||
|
||||
class ChooseYourPassSection extends StatefulWidget {
|
||||
const ChooseYourPassSection({super.key});
|
||||
final List<CardModel> cards; // 👈 from API
|
||||
|
||||
const ChooseYourPassSection({
|
||||
super.key,
|
||||
required this.cards,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChooseYourPassSection> createState() => _ChooseYourPassSectionState();
|
||||
@@ -19,21 +25,6 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
|
||||
|
||||
int _currentPage = 0;
|
||||
|
||||
final List<Map<String, dynamic>> passes = [
|
||||
{
|
||||
"title": "Chicago-\n${CommonAppText.selectiveCard} CARD",
|
||||
"price": "\$50",
|
||||
"color": const Color(0xffF95FAF),
|
||||
"bgColor": const Color(0xFFFDE7F1),
|
||||
},
|
||||
{
|
||||
"title": "Chicago-\nUnlimited CARD",
|
||||
"price": "\$120",
|
||||
"color": const Color(0xffF95F62),
|
||||
"bgColor": const Color(0xFFFFE8E8),
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -51,10 +42,13 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.cards.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ===== TITLE =====
|
||||
Text(
|
||||
"Choose your Pass",
|
||||
style: GoogleFonts.poppins(
|
||||
@@ -71,28 +65,27 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ===== STATIC PAGEVIEW (no animation) =====
|
||||
// ===== PAGEVIEW =====
|
||||
SizedBox(
|
||||
height: 430,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: passes.length,
|
||||
itemCount: widget.cards.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = passes[index];
|
||||
return _buildPassCard(item);
|
||||
return _buildPassCard(widget.cards[index], index);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ===== INDICATOR =====
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(passes.length, (index) {
|
||||
children: List.generate(widget.cards.length, (index) {
|
||||
bool isActive = index == _currentPage;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
@@ -113,61 +106,76 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== CARD BUILDER =====
|
||||
Widget _buildPassCard(Map<String, dynamic> item) {
|
||||
// ===== CARD UI =====
|
||||
Widget _buildPassCard(CardModel card, int index) {
|
||||
final Color primaryColor =
|
||||
index.isEven ? const Color(0xffF95FAF) : const Color(0xffF95F62);
|
||||
|
||||
final Color bgColor =
|
||||
index.isEven ? const Color(0xFFFDE7F1) : const Color(0xFFFFE8E8);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: item['bgColor'],
|
||||
border: Border.all(color: item['color'].withOpacity(0.6)),
|
||||
color: bgColor,
|
||||
border: Border.all(color: primaryColor.withOpacity(0.6)),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// TITLE FROM API
|
||||
Text(
|
||||
item['title'],
|
||||
card.title ?? "",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: item['color'],
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
|
||||
// PRICE FROM API
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: "From ",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Color(0xff535353),
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "From ",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xff535353),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: "\$${card.adultPrice ?? 0}",
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextSpan(
|
||||
text: item['price'],
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: item['color'],
|
||||
fontWeight: FontWeight.w600
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// DESCRIPTION FROM API
|
||||
Text(
|
||||
"Dive into an extensive selection of thrilling destinations, "
|
||||
"thoughtfully categorized to help you find the perfect getaway.",
|
||||
card.description ?? "",
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12,
|
||||
color: Color(0xff5B5F62),
|
||||
color: const Color(0xff5B5F62),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 🔒 STATIC TEXT (NOT REMOVED)
|
||||
const Text(
|
||||
"• Fusce tincidunt interdum ex, in tincidunt libero porttitor vel.\n"
|
||||
"• Pellentesque vel nisl posuere, ullamcorper nibh.\n"
|
||||
@@ -178,7 +186,9 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
@@ -186,7 +196,7 @@ class _ChooseYourPassSectionState extends State<ChooseYourPassSection> {
|
||||
Navigator.of(context).pushNamed(RouteConstants.buyPass);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: item['color'],
|
||||
backgroundColor: primaryColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
|
||||
18
lib/localPreference/local_preference.dart
Normal file
18
lib/localPreference/local_preference.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LocalPreference {
|
||||
static int? _selectedCityId;
|
||||
|
||||
/// Save selected city ID
|
||||
static Future<void> setSelectedCityId(int value) async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('selected_city_id', value);
|
||||
}
|
||||
|
||||
/// Get selected city ID
|
||||
static Future<int> getSelectedCityId() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
_selectedCityId = prefs.getInt('selected_city_id') ?? 0;
|
||||
return _selectedCityId!;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'core/app_router.dart';
|
||||
import 'home/bloc/FirstTimeUserHome/first_time_user_home_bloc.dart';
|
||||
import 'home/bloc/FirstTimeUserHome/first_time_user_home_event.dart';
|
||||
import 'home/bloc/registeredHome/home_bloc.dart';
|
||||
import 'home/repository/first_time_user_home_repository.dart';
|
||||
import 'home/repository/home_repository.dart';
|
||||
import 'my_pass/blocs/my_pass_bloc.dart';
|
||||
|
||||
void main() {
|
||||
@@ -46,6 +48,11 @@ class MyApp extends StatelessWidget {
|
||||
FirstTimeUserHomeRepository(),
|
||||
)..add(FetchFirstTimeUserHomeEvent()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => HomeBloc(
|
||||
homeRepository: HomeRepository(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
onGenerateRoute: _appRouter.onGenerateRoute,
|
||||
|
||||
@@ -5,4 +5,7 @@ class ApiUrls {
|
||||
static const cityList = "$baseUrl/mobile/city_list";
|
||||
static const upcomingCityList = "$baseUrl/mobile/upcoming_cities";
|
||||
static const searchCityList = "$baseUrl/mobile/city-selection";
|
||||
static const attractionsList = "$baseUrl/mobile/list/all";
|
||||
static const attractionDetails = "$baseUrl/mobile/list";
|
||||
static const home = "$baseUrl/mobile";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:citycards_customer/attraction_details/share_bottomsheet.dart';
|
||||
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_bullet_points.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Reference in New Issue
Block a user