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:
mystery012728
2026-01-19 19:10:14 +05:30
parent d3abf4053a
commit a55510a482
30 changed files with 2347 additions and 1087 deletions

View File

@@ -1,484 +0,0 @@
import 'package:citycards_customer/attraction_details/share_bottomsheet.dart';
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/custom_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../core/route_constants.dart';
class AttractionDetailsView extends StatelessWidget {
const AttractionDetailsView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Image.asset(
'assets/images/koh_rong_samloem_banner.png',
height: 377.h,
width: double.infinity,
fit: BoxFit.cover,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(isWhiteLogo: true, isProfilePage: false, showDivider: true,),
SizedBox(height: 10.h),
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(
Icons.arrow_back,
size: 24.sp,
color: Colors.white,
),
),
SizedBox(width: 8.w),
Text(
"Koh Rong Samloem",
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
],
),
),
),
),
Positioned(
bottom: 31.h,
left: 12.w,
child: Text(
"Koh Rong\nSamloem",
style: TextStyle(
color: Colors.white,
fontSize: 44.sp,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
Positioned(
bottom: 31.h,
right: 17.w,
child: GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const ShareBottomSheet(),
);
},
child: Container(
height: 36.h,
width: 36.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20.r),
),
child: Center(
child: Icon(
Icons.share_sharp,
color: Colors.black,
size: 18.sp,
),
),
),
),
),
],
),
// About Section
Padding(
padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 30.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"About",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 12.32.h),
Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non...",
style: TextStyle(
color: Color(0xFF262626),
fontWeight: FontWeight.w400,
fontSize: 14.sp,
height: 1.5,
),
),
],
),
),
SizedBox(height: 41.h),
// Booking Section
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"How to make a booking?",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Color(0xFFF95F62)),
),
child: Row(
children: [
Icon(
Icons.call,
color: Color(0xFFF95F62),
size: 32.w,
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Contact Number",
color: Colors.black.withOpacity(.6),
size: 12.sp,
weight: FontWeight.w500,
),
SizedBox(height: 6.h),
CustomText(
text: "+1012 3456 789",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
),
SizedBox(height: 6.h),
CustomText(
text: "Tap to call",
color: Colors.black.withOpacity(.4),
size: 12.sp,
weight: FontWeight.w400,
),
],
),
),
],
),
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 12.h,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Color(0xFFF95F62)),
),
child: Row(
children: [
Icon(
Icons.email_sharp,
color: Color(0xFFF95F62),
size: 32.w,
),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Email",
color: Colors.black.withOpacity(.6),
size: 12.sp,
weight: FontWeight.w500,
),
SizedBox(height: 6.h),
CustomText(
text: "CityCards24@gmail.com",
color: Colors.black,
size: 14.sp,
weight: FontWeight.w600,
),
SizedBox(height: 6.h),
CustomText(
text: "Tap to email",
color: Colors.black.withOpacity(.4),
size: 12.sp,
weight: FontWeight.w400,
),
],
),
),
],
),
),
SizedBox(height: 16.h),
InkWell(
onTap: (){
Navigator.of(context).pushNamed(RouteConstants.makeBooking);
},
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 24.w,
vertical: 18.h,
),
decoration: BoxDecoration(
color: Color(0xFFF95F62),
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomText(
text: "Via CityCards",
size: 16.sp,
weight: FontWeight.w500,
color: Colors.white,
),
SizedBox(height: 8.h),
CustomText(
text: "Create a booking via app",
size: 11.sp,
weight: FontWeight.w400,
color: Colors.white,
),
],
),
),
Icon(
Icons.arrow_forward_ios_outlined,
color: Colors.white,
),
],
),
),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"What is included",
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 4.h),
Wrap(
runSpacing: 16.h,
spacing: 16.w,
children: [
includedBox(
"assets/icons/bus.png",
"Bus",
"Transportation",
),
includedBox(
"assets/icons/clock.png",
"2 day 1 night",
"Duration",
),
includedBox(
"assets/icons/bx_qr.png",
"TAC200812695",
"Product code",
),
],
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"Exact Location",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 8.h),
CustomText(
text: "View the location on map",
size: 12.sp,
color: Colors.black.withOpacity(.6),
),
SizedBox(height: 17.h),
ClipRRect(
borderRadius: BorderRadius.circular(13.54.r),
child: Image.asset(
height: 178.7.h,
width: double.infinity,
"assets/images/attra_detail_map.png",
fit: BoxFit.cover,
),
),
SizedBox(height: 17.h),
CustomText(
text:
"Angkor Mails Hotel \nNR6, Krong Siem Reap Cambodia",
size: 12.sp,
color: Colors.black.withOpacity(0.6),
),
SizedBox(height: 30.h),
Divider(color: Colors.black.withOpacity(0.2)),
SizedBox(height: 30.h),
Text(
"People frequently ask",
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 15.h),
faqBox(
"About this place",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
),
SizedBox(height: 15.h),
faqBox(
"Term and condition",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
),
SizedBox(height: 15.h),
faqBox(
"Cancellation Policy",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. A id diam nisl, non justo, in odio...",
),
],
),
),
SizedBox(height: 24.h),
],
),
),
),
);
}
Widget includedBox(String icon, String title, String disc) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
borderRadius: BorderRadius.circular(10.r),
border: Border.all(color: Color(0xFFFDCDCE)),
),
child: IntrinsicWidth(
child: Row(
children: [
Image.asset(icon, scale: 4),
SizedBox(width: 16.w),
Column(
children: [
CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: Color(0xFF212121),
),
SizedBox(height: 4.h),
CustomText(
text: disc,
size: 11.sp,
weight: FontWeight.w400,
color: Color(0xFF666666),
),
],
),
],
),
),
);
}
Widget faqBox(String title, String desc) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: Color(0xFFFFF5F5),
border: Border.all(color: Color(0xFFFDCDCE)),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
children: [
Row(
children: [
CustomText(
text: title,
size: 16.sp,
weight: FontWeight.w500,
color: Color(0xFF212121),
),
SizedBox(width: 20.w),
Icon(Icons.arrow_forward_ios_outlined, size: 18.sp),
],
),
SizedBox(height: 9.h),
CustomText(text: desc, size: 11.sp, color: Color(0xFF7D7D7D)),
],
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'attraction_details_event.dart';
import 'attraction_details_state.dart';
import '../repository/attraction_details_repository.dart';
class AttractionDetailsBloc
extends Bloc<AttractionDetailsEvent, AttractionDetailsState> {
final AttractionDetailsRepository repository;
AttractionDetailsBloc({
required this.repository,
}) : super(AttractionDetailsInitial()) {
on<FetchAttractionDetails>(_onFetchAttractionDetails);
}
Future<void> _onFetchAttractionDetails(
FetchAttractionDetails event,
Emitter<AttractionDetailsState> emit,
) async {
emit(AttractionDetailsLoading());
try {
final response = await repository.fetchAttractionDetails(
attractionId: event.attractionId,
);
emit(
AttractionDetailsLoaded(
attractionDetails: response,
),
);
} catch (e) {
emit(
AttractionDetailsError(
message: e.toString(),
),
);
}
}
}

View File

@@ -0,0 +1,19 @@
import 'package:equatable/equatable.dart';
abstract class AttractionDetailsEvent extends Equatable {
const AttractionDetailsEvent();
@override
List<Object?> get props => [];
}
class FetchAttractionDetails extends AttractionDetailsEvent {
final int attractionId;
const FetchAttractionDetails({
required this.attractionId,
});
@override
List<Object?> get props => [attractionId];
}

View File

@@ -0,0 +1,36 @@
import 'package:equatable/equatable.dart';
import '../models/attraction_details_model.dart';
abstract class AttractionDetailsState extends Equatable {
const AttractionDetailsState();
@override
List<Object?> get props => [];
}
class AttractionDetailsInitial extends AttractionDetailsState {}
class AttractionDetailsLoading extends AttractionDetailsState {}
class AttractionDetailsLoaded extends AttractionDetailsState {
final AttractionDetailsModel attractionDetails;
const AttractionDetailsLoaded({
required this.attractionDetails,
});
@override
List<Object?> get props => [attractionDetails];
}
class AttractionDetailsError extends AttractionDetailsState {
final String message;
const AttractionDetailsError({
required this.message,
});
@override
List<Object?> get props => [message];
}

View File

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

View File

@@ -0,0 +1,17 @@
import '../models/attraction_details_model.dart';
import '../../networkApiServices/network_api_services.dart';
import '../../networkApiServices/api_urls.dart';
class AttractionDetailsRepository {
final NetworkApiService _apiService = NetworkApiService();
/// Fetch attraction details by attractionId
Future<AttractionDetailsModel> fetchAttractionDetails({
required int attractionId,
}) async {
final response = await _apiService.getApi(
url: '${ApiUrls.attractionDetails}/$attractionId',
);
return AttractionDetailsModel.fromJson(response.data);
}
}

View File

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

View File

@@ -1,34 +1,42 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/attraction_model.dart';
import '../repository/attractions_repository.dart';
part 'attractions_event.dart';
part 'attractions_state.dart';
import 'attractions_event.dart';
import 'attractions_state.dart';
class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
final AttractionsRepository repository;
AttractionsBloc(this.repository) : super(AttractionsInitial()) {
on<LoadAttractions>((event, emit) {
final attractions = repository.fetchAttractions();
emit(AttractionsLoaded(attractions));
});
on<LoadMyPassAttraction>((event, emit) {
final attractions = repository.fetchMyPassAttraction();
emit(AttractionsLoaded(attractions));
});
on<SearchAttractions>((event, emit) {
if (state is AttractionsLoaded) {
final currentState = state as AttractionsLoaded;
final filtered = currentState.attractions
.where((a) =>
a.title.toLowerCase().contains(event.query.toLowerCase()) ||
a.location.toLowerCase().contains(event.query.toLowerCase()))
.toList();
emit(AttractionsLoaded(filtered));
}
});
AttractionsBloc({required this.repository})
: super(AttractionsInitial()) {
on<FetchAttractionsByCategory>(_onFetchAttractionsByCategory);
}
}
Future<void> _onFetchAttractionsByCategory(
FetchAttractionsByCategory event,
Emitter<AttractionsState> emit,
) async {
emit(AttractionsLoading());
try {
final AttractionsResponse response =
await repository.fetchAttractionsByCategory(
categoryXid: event.categoryXid, // Can be null now
);
emit(
AttractionsLoaded(
attractions: response.attractions ?? [],
categories: response.categories ?? [],
selectedCategoryId: event.categoryXid, // Can be null
),
);
} catch (e) {
emit(
AttractionsError(
e.toString(),
),
);
}
}
}

View File

@@ -1,12 +1,17 @@
part of 'attractions_bloc.dart';
import 'package:equatable/equatable.dart';
abstract class AttractionsEvent {}
abstract class AttractionsEvent extends Equatable {
const AttractionsEvent();
class LoadAttractions extends AttractionsEvent {}
class LoadMyPassAttraction extends AttractionsEvent {}
class SearchAttractions extends AttractionsEvent {
final String query;
SearchAttractions(this.query);
@override
List<Object?> get props => [];
}
class FetchAttractionsByCategory extends AttractionsEvent {
final int? categoryXid; // Make it nullable
const FetchAttractionsByCategory({this.categoryXid}); // Remove required
@override
List<Object?> get props => [categoryXid];
}

View File

@@ -1,10 +1,37 @@
part of 'attractions_bloc.dart';
import 'package:equatable/equatable.dart';
import '../models/attraction_model.dart';
abstract class AttractionsState {}
abstract class AttractionsState extends Equatable {
const AttractionsState();
@override
List<Object?> get props => [];
}
class AttractionsInitial extends AttractionsState {}
class AttractionsLoading extends AttractionsState {}
class AttractionsLoaded extends AttractionsState {
final List<Attraction> attractions;
AttractionsLoaded(this.attractions);
final List<Category> categories;
final int? selectedCategoryId; // Make it nullable
const AttractionsLoaded({
required this.attractions,
required this.categories,
this.selectedCategoryId, // Remove required
});
@override
List<Object?> get props => [attractions, categories, selectedCategoryId];
}
class AttractionsError extends AttractionsState {
final String message;
const AttractionsError(this.message);
@override
List<Object?> get props => [message];
}

View File

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

View File

@@ -1,115 +1,26 @@
import 'package:citycards_customer/common_packages/common_app_texts.dart';
import 'package:citycards_customer/networkApiServices/api_urls.dart';
import '../../networkApiServices/network_api_services.dart';
import '../models/attraction_model.dart';
class AttractionsRepository {
List<Attraction> fetchAttractions() {
return [
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_1.jpg",
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Siem Reap",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_2.jpg",
tags: ["Unlimited Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_3.jpg",
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_4.jpg",
tags: ["${CommonAppText.selectiveCard} Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_5.jpg",
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
isBookingRequired: false,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
];
}
final NetworkApiService _apiServices = NetworkApiService();
List<Attraction> fetchMyPassAttraction() {
return [
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_1.jpg",
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Siem Reap",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_2.jpg",
tags: ["Unlimited Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_3.jpg",
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_4.jpg",
tags: ["${CommonAppText.selectiveCard} Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
Attraction(
title: "Dart Palace",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_5.jpg",
tags: ["Unlimited Card", "${CommonAppText.selectiveCard} Card"],
isBookingRequired: true,
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Convallis condimentum morbi non egestas enim amet sagittis. Proin sed aliquet rhoncus ut pellentesque ullamcorper sit eget ac.Sit nisi, cras amet varius eget egestas pellentesque. Cursus gravida euismod non... ",
),
];
/// Fetch attractions by categoryXid (optional)
Future<AttractionsResponse> fetchAttractionsByCategory({
int? categoryXid, // Make it nullable
}) async {
try {
// Build URL with or without categoryXid
String url = ApiUrls.attractionsList;
if (categoryXid != null) {
url = '$url?categoryXid=$categoryXid';
}
final response = await _apiServices.getApi(url: url);
return AttractionsResponse.fromJson(response.data);
} catch (e) {
throw Exception('Failed to fetch attractions: $e');
}
}
}
}

View File

@@ -3,8 +3,11 @@ import 'package:citycards_customer/common_packages/back_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../common_packages/custom_search_field.dart';
import '../blocs/attractions_bloc.dart';
import '../blocs/attractions_event.dart';
import '../blocs/attractions_state.dart';
import '../repository/attractions_repository.dart';
import '../widget/attraction_card.dart';
import '../widget/filter_chip.dart';
@@ -17,14 +20,13 @@ class AttractionsPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
final bloc = AttractionsBloc(AttractionsRepository());
final bloc = AttractionsBloc(
repository: AttractionsRepository(),
);
// 🔥 Trigger event based on source
if (source == "home") {
bloc.add(LoadAttractions());
} else if (source == "qrPass") {
bloc.add(LoadMyPassAttraction());
}
bloc.add(
const FetchAttractionsByCategory(), // No categoryXid parameter
);
return bloc;
},
@@ -41,42 +43,73 @@ class AttractionsPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App bar
CommonAppBar(isWhiteLogo: false, isProfilePage: false, showDivider: true),
CommonAppBar(
isWhiteLogo: false,
isProfilePage: false,
showDivider: true,
),
backWidget(context, "Your Attraction", Colors.black),
const SizedBox(height: 20),
// 🔍 Search field
// 🔍 Search field (UI kept, logic disabled)
CommonSearchField(
hint: "Search attractions...",
hintColor: Colors.grey.shade500,
onChanged: (value) {
if (value.isEmpty) {
bloc.add(LoadAttractions());
} else {
bloc.add(SearchAttractions(value));
}
// ❌ Search logic intentionally disabled
// UI only, no API call
},
),
const SizedBox(height: 16),
// 🏝 Category chips row
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
buildCategoryChip("Beach"),
buildCategoryChip("Hike"),
buildCategoryChip("Popular"),
buildCategoryChip("Best in Summer"),
],
// 🏖 Category chips row - DYNAMIC
if (state is AttractionsLoaded)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: state.categories
.map(
(category) => buildCategoryChip(
category.categoryName ?? '',
isSelected: state.selectedCategoryId == category.id,
onTap: () {
bloc.add(
FetchAttractionsByCategory(
categoryXid: category.id,
),
);
},
),
)
.toList(),
),
),
),
// else
// // Show placeholder chips while loading
// SingleChildScrollView(
// scrollDirection: Axis.horizontal,
// child: Row(
// children: [
// buildCategoryChip("Beach", isSelected: true, onTap: () {}),
// buildCategoryChip("Hike", isSelected: false, onTap: () {}),
// buildCategoryChip("Adventure", isSelected: false, onTap: () {}),
// buildCategoryChip("Best in Summer", isSelected: false, onTap: () {}),
// ],
// ),
// ),
const SizedBox(height: 10),
// 🏙 Attraction list
if (state is AttractionsLoaded)
// 🙏 Attraction list
if (state is AttractionsLoading)
const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(),
),
)
else if (state is AttractionsLoaded)
state.attractions.isEmpty
? Center(
child: Padding(
@@ -84,7 +117,7 @@ class AttractionsPage extends StatelessWidget {
child: Text(
"No attractions found",
style: TextStyle(
color: Colors.grey[600],
color: Colors.grey,
fontSize: 14.sp,
),
),
@@ -92,17 +125,28 @@ class AttractionsPage extends StatelessWidget {
)
: Column(
children: state.attractions
.map((attraction) => AttractionCard(
attraction: attraction))
.map(
(attraction) => AttractionCard(
attraction: attraction,
),
)
.toList(),
)
else
const Center(
child: Padding(
padding: EdgeInsets.only(top: 60),
child: CircularProgressIndicator(),
),
),
else if (state is AttractionsError)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
state.message,
style: TextStyle(
color: Colors.red,
fontSize: 14.sp,
),
),
),
)
else
const SizedBox(),
],
),
),
@@ -112,4 +156,4 @@ class AttractionsPage extends StatelessWidget {
),
);
}
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../common_packages/common_app_texts.dart';
import '../../core/route_constants.dart';
@@ -10,64 +11,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,
),
),
),
],
),
),

View File

@@ -1,20 +1,33 @@
import "package:flutter/material.dart";
import 'package:flutter/material.dart';
Widget buildCategoryChip(String label) {
return Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xffF95F62),
borderRadius: BorderRadius.circular(40),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
Widget buildCategoryChip(
String label, {
required bool isSelected,
VoidCallback? onTap,
}) {
const Color redColor = Color(0xffF95F62);
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? redColor : redColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(40),
border: Border.all(
color: redColor,
width: 1,
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : redColor,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
);
}
}

View File

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

View File

@@ -1,3 +1,4 @@
import 'package:citycards_customer/attractions/models/attraction_model.dart';
import 'package:citycards_customer/core/route_constants.dart';
import 'package:citycards_customer/home/views/registered_user_home_page.dart';
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
@@ -5,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);
},
);

View File

@@ -0,0 +1,25 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../repository/home_repository.dart';
import 'home_event.dart';
import 'home_state.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final HomeRepository homeRepository;
HomeBloc({required this.homeRepository}) : super(HomeInitial()) {
on<FetchHomeData>(_onFetchHomeData);
}
Future<void> _onFetchHomeData(
FetchHomeData event,
Emitter<HomeState> emit,
) async {
emit(HomeLoading());
try {
final homeModel = await homeRepository.fetchHomeData();
emit(HomeLoaded(homeModel));
} catch (e) {
emit(HomeError(e.toString()));
}
}
}

View File

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

View File

@@ -0,0 +1,19 @@
import '../../model/home_model.dart';
abstract class HomeState {}
class HomeInitial extends HomeState {}
class HomeLoading extends HomeState {}
class HomeLoaded extends HomeState {
final HomeModel homeModel;
HomeLoaded(this.homeModel);
}
class HomeError extends HomeState {
final String message;
HomeError(this.message);
}

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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