working on my pass section

This commit is contained in:
2025-10-29 16:13:10 +05:30
parent 85c17595f2
commit 92ce97b553
21 changed files with 1068 additions and 113 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
assets/images/qr_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -14,6 +14,11 @@ class AttractionsBloc extends Bloc<AttractionsEvent, AttractionsState> {
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;

View File

@@ -4,6 +4,8 @@ abstract class AttractionsEvent {}
class LoadAttractions extends AttractionsEvent {}
class LoadMyPassAttraction extends AttractionsEvent {}
class SearchAttractions extends AttractionsEvent {
final String query;
SearchAttractions(this.query);

View File

@@ -4,6 +4,8 @@ class Attraction {
final String price;
final String image;
final List<String> tags;
final bool isBookingRequired;
final String description;
Attraction({
required this.title,
@@ -11,5 +13,7 @@ class Attraction {
required this.price,
required this.image,
required this.tags,
required this.isBookingRequired,
required this.description
});
}

View File

@@ -9,6 +9,9 @@ class AttractionsRepository {
price: "\$25",
image: "assets/dummy/dummy_1.jpg",
tags: ["Unlimited Card", "Flexi 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",
@@ -16,6 +19,9 @@ class AttractionsRepository {
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",
@@ -23,6 +29,9 @@ class AttractionsRepository {
price: "\$25",
image: "assets/dummy/dummy_3.jpg",
tags: ["Unlimited Card", "Flexi 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",
@@ -30,6 +39,9 @@ class AttractionsRepository {
price: "\$25",
image: "assets/dummy/dummy_4.jpg",
tags: ["Flexi 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",
@@ -37,6 +49,64 @@ class AttractionsRepository {
price: "\$25",
image: "assets/dummy/dummy_5.jpg",
tags: ["Unlimited Card", "Flexi 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... ",
),
];
}
List<Attraction> fetchMyPassAttraction() {
return [
Attraction(
title: "Koh Rong Samloem",
location: "Krong Siem Reap",
price: "\$25",
image: "assets/dummy/dummy_1.jpg",
tags: ["Unlimited Card", "Flexi 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", "Flexi 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: ["Flexi 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", "Flexi 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... ",
),
];
}

View File

@@ -1,4 +1,5 @@
import 'package:citycards_customer/common_packages/app_bar.dart';
import 'package:citycards_customer/common_packages/back_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
@@ -9,12 +10,24 @@ import '../widget/attraction_card.dart';
import '../widget/filter_chip.dart';
class AttractionsPage extends StatelessWidget {
const AttractionsPage({super.key});
final String source;
const AttractionsPage({super.key, required this.source});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => AttractionsBloc(AttractionsRepository())..add(LoadAttractions()),
create: (_) {
final bloc = AttractionsBloc(AttractionsRepository());
// 🔥 Trigger event based on source
if (source == "home") {
bloc.add(LoadAttractions());
} else if (source == "qrPass") {
bloc.add(LoadMyPassAttraction());
}
return bloc;
},
child: BlocBuilder<AttractionsBloc, AttractionsState>(
builder: (context, state) {
final bloc = context.read<AttractionsBloc>();
@@ -29,26 +42,7 @@ class AttractionsPage extends StatelessWidget {
children: [
// App bar
CommonAppBar(isWhiteLogo: false, isProfilePage: false),
SizedBox(height: 22.h),
// Back row
Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Icon(Icons.arrow_back, size: 24.sp),
),
SizedBox(width: 8.w),
Text(
"Your Attraction",
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: Colors.black87,
),
),
],
),
backWidget(context, "Your Attraction"),
const SizedBox(height: 20),
// 🔍 Search field

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/route_constants.dart';
import '../models/attraction_model.dart';
class AttractionCard extends StatelessWidget {
@@ -8,7 +9,11 @@ class AttractionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
return InkWell(
onTap: (){
Navigator.of(context).pushNamed(RouteConstants.attractionDetails);
},
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -33,13 +38,19 @@ class AttractionCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(attraction.title,
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w500)),
Text(
attraction.title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 6),
Text(attraction.location,
Text(
attraction.location,
style: GoogleFonts.poppins(
fontSize: 12, fontWeight: FontWeight.w400, color: Color(0xff464646))),
fontSize: 12,
fontWeight: FontWeight.w400,
color: Color(0xff464646),
),
),
const SizedBox(height: 6),
Text.rich(
TextSpan(
@@ -54,22 +65,32 @@ class AttractionCard extends StatelessWidget {
),
const TextSpan(
text: "/person",
style:
TextStyle(fontSize: 10, color: Colors.black, fontWeight: FontWeight.w400,),
style: TextStyle(
fontSize: 10,
color: Colors.black,
fontWeight: FontWeight.w400,
),
),
],
),
),
const SizedBox(height: 6),
Wrap(
attraction.isBookingRequired == false
? Wrap(
spacing: 6,
children: attraction.tags
.map((tag) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
.map(
(tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: tag == "Flexi Card"
? const Color(0xffF95FAF).withOpacity(0.1)
: const Color(0xffF95F62).withOpacity(0.1),
: const Color(
0xffF95F62,
).withOpacity(0.1),
border: Border.all(
color: tag == "Flexi Card"
? const Color(0xffF95FAF)
@@ -85,15 +106,37 @@ class AttractionCard extends StatelessWidget {
fontWeight: FontWeight.w400,
),
),
))
),
)
.toList(),
)
,
: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
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,
),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -40,7 +40,8 @@ class AppRouter {
},
);
case RouteConstants.attractionsPage:
return MaterialPageRoute(builder: (_) => const AttractionsPage());
final args = settings.arguments as String;
return MaterialPageRoute(builder: (_) => AttractionsPage(source: args));
case RouteConstants.profile:
return MaterialPageRoute(
builder: (_) {

View File

@@ -1,9 +1,12 @@
import 'package:citycards_customer/core/route_constants.dart';
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
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 '../attractions/views/attractions_page_view.dart';
import '../my_pass/views/qr_pass_page_view.dart';
import '../postcard/blocs/postcard_creation_bloc.dart';
import '../postcard/views/postcard_creation_page_view.dart';
@@ -24,10 +27,15 @@ Widget buildOffstageNavigator(
// 🔹 Attractions Page
case RouteConstants.attractionsPage:
final args = settings.arguments as String;
return MaterialPageRoute(
builder: (_) => const AttractionsPage(),
builder: (_) => AttractionsPage(source: args,),
);
case RouteConstants.attractionDetails:
return MaterialPageRoute(builder: (_) {
return AttractionDetailsView();
});
// 🔹 Upload Photo Page (start of postcard creation flow)
case RouteConstants.uploadPhotoPage:
return MaterialPageRoute(
@@ -49,6 +57,18 @@ Widget buildOffstageNavigator(
},
);
case RouteConstants.qrPage:
return MaterialPageRoute(
builder: (context) {
final previousBloc = BlocProvider.of<MyPassBloc>(context);
return BlocProvider.value(
value: previousBloc,
child: const QrPassView(),
);
},
);
default:
return MaterialPageRoute(
builder: (_) => const Scaffold(

View File

@@ -42,4 +42,6 @@ class RouteConstants {
/************************** My card page ***************************************/
static const String cartPage = '/cartPage';
static const String qrPage = '/qrPage';
}

View File

@@ -5,6 +5,7 @@ import '../../common_bloc/bottom_navigation_bloc.dart';
import '../../common_packages/custom_bottom_navbar.dart';
import '../../core/inside_bottom_navigator.dart';
import '../../itinerary_creation/views/itinerary_creation_start_view.dart';
import '../../my_pass/views/my_pass_page_view.dart';
import '../../postcard/views/postcard_initial_page_view.dart';
import 'first_time_user_home_page.dart';
@@ -36,6 +37,7 @@ class _HomePageState extends State<HomePage> {
children: [
buildOffstageNavigator(0, currentIndex, const FirstTimeUserHomePage(), _navigatorKeys[0]),
buildOffstageNavigator(1, currentIndex, const ItineraryCreationStartPage(), _navigatorKeys[1]),
buildOffstageNavigator(2, currentIndex, const MyPassesView(), _navigatorKeys[2]),
buildOffstageNavigator(3, currentIndex, const PostcardPage(), _navigatorKeys[3]),
],
),

View File

@@ -127,7 +127,7 @@ class _RegisteredUserHomePageState extends State<RegisteredUserHomePage> {
),
InkWell(
onTap: (){
Navigator.of(context).pushNamed(RouteConstants.attractionsPage);
Navigator.of(context).pushNamed(RouteConstants.attractionsPage, arguments: "home");
},
child: Text("View all",
style: TextStyle(

View File

@@ -1,12 +1,15 @@
import 'package:citycards_customer/cart/blocs/postcard_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import 'core/app_router.dart';
import 'core/route_constants.dart';
import 'my_pass/blocs/my_pass_bloc.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.white,
@@ -14,6 +17,7 @@ void main() {
statusBarBrightness: Brightness.light,
),
);
runApp(MyApp());
}
@@ -27,7 +31,13 @@ class MyApp extends StatelessWidget {
return ScreenUtilInit(
designSize: const Size(390, 844),
builder: (context, child) {
return MaterialApp(
return MultiBlocProvider(
providers: [
BlocProvider<MyPassBloc>(
create: (_) => MyPassBloc()..add(LoadMyPasses()),
),
],
child: MaterialApp(
onGenerateRoute: _appRouter.onGenerateRoute,
debugShowCheckedModeBanner: false,
title: 'City Cards',
@@ -36,6 +46,7 @@ class MyApp extends StatelessWidget {
Theme.of(context).textTheme,
),
),
),
);
},
);

View File

@@ -0,0 +1,55 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/my_pass_model.dart';
abstract class MyPassEvent {}
class LoadMyPasses extends MyPassEvent {}
abstract class MyPassState {}
class MyPassLoading extends MyPassState {}
class SelectPass extends MyPassEvent {
final MyPassModel selectedPass;
SelectPass(this.selectedPass);
}
class MyPassEmpty extends MyPassState {}
class MyPassLoaded extends MyPassState {
final List<MyPassModel> passes;
final MyPassModel? selectedPass;
MyPassLoaded(this.passes, this.selectedPass);
}
class MyPassBloc extends Bloc<MyPassEvent, MyPassState> {
MyPassBloc() : super(MyPassLoading()) {
on<LoadMyPasses>((event, emit) async {
await Future.delayed(const Duration(milliseconds: 500));
final List<MyPassModel> passes = [
MyPassModel(
imageUrl:
"assets/images/city_melbourne.png",
title: "Unlimited Card",
city: "Melbourne",
validity: "20/09/2025",
adults: 3,
kids: 3,
duration: "2 Days",
isActive: true,
),
];
// If no passes, show empty screen
if (passes.isEmpty) {
emit(MyPassEmpty());
} else {
emit(MyPassLoaded(passes, null));
}
});
on<SelectPass>((event, emit) {
if (state is MyPassLoaded) {
final current = state as MyPassLoaded;
emit(MyPassLoaded(current.passes, event.selectedPass));
}
});
}
}

View File

@@ -0,0 +1,21 @@
class MyPassModel {
final String imageUrl;
final String title;
final String city;
final String validity;
final int adults;
final int kids;
final String duration;
final bool isActive;
MyPassModel({
required this.imageUrl,
required this.title,
required this.city,
required this.validity,
required this.adults,
required this.kids,
required this.duration,
required this.isActive,
});
}

View File

@@ -0,0 +1,168 @@
import 'package:citycards_customer/common_packages/app_bar.dart';
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 '../../core/route_constants.dart';
import '../blocs/my_pass_bloc.dart';
import '../widgets/pass_widget.dart';
class MyPassesView extends StatelessWidget {
const MyPassesView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<MyPassBloc, MyPassState>(
builder: (context, state) {
if (state is MyPassLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is MyPassEmpty) {
return _noPassView(context);
} else if (state is MyPassLoaded) {
return _passListView(state.passes);
}
return const SizedBox.shrink();
},
);
}
Widget _noPassView(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 30.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/no_pass.png', // your woman sitting image
height: 180.h,
),
SizedBox(height: 20.h),
Text(
"You Dont have a Pass Yet! 😕",
style: GoogleFonts.poppins(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.black,
),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
Text(
"Get a pass and get offers and discounts and\nmore on your trip to your favourite city",
style: GoogleFonts.poppins(fontSize: 12.sp, color: Colors.black54),
textAlign: TextAlign.center,
),
SizedBox(height: 24.h),
GestureDetector(
onTap: () {
// Navigate to Buy a Pass
Navigator.pushNamed(context, '/buyPass');
},
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 14.h),
decoration: BoxDecoration(
color: const Color(0xffFF5A5F),
borderRadius: BorderRadius.circular(30.r),
),
child: Center(
child: Text(
"Buy a Pass",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
);
}
Widget _passListView(List passes) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonAppBar(isWhiteLogo: false, isProfilePage: false),
SizedBox(height: 10.h),
Row(
children: [
Container(
width: 130.w,
height: 36.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: const Color(0xffFEE7E7),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: const Color(0xffFDCDCE)),
),
child: Row(
children: [
Text(
"Sort by Date",
style: GoogleFonts.poppins(fontSize: 12.sp),
),
const Spacer(),
const Icon(Icons.sort, size: 16),
],
),
),
SizedBox(width: 10.w),
Container(
height: 36.h,
width: 130.w,
padding: EdgeInsets.symmetric(horizontal: 12.w),
decoration: BoxDecoration(
color: const Color(0xffFEE7E7),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: const Color(0xffFDCDCE)),
),
child: Row(
children: [
Text(
"All",
style: GoogleFonts.poppins(fontSize: 12.sp),
),
const Spacer(),
const Icon(Icons.keyboard_arrow_down_rounded, size: 18),
],
),
),
],
),
SizedBox(height: 20.h),
ListView.builder(
itemCount: passes.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final pass = passes[index];
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: InkWell(
onTap: (){
context.read<MyPassBloc>().add(SelectPass(pass));
Navigator.of(
context,
).pushNamed(RouteConstants.qrPage);
},
child: PassTicketCard(pass: pass),
),
);
},
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:citycards_customer/my_pass/blocs/my_pass_bloc.dart';
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_packages/app_bar.dart';
import '../../common_packages/back_widget.dart';
import '../../core/route_constants.dart';
import '../widgets/action_button_widget.dart';
import '../widgets/qr_container_widget.dart';
class QrPassView extends StatelessWidget {
const QrPassView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<MyPassBloc, MyPassState>(
builder: (context, state) {
if (state is MyPassLoaded) {
final pass = state.selectedPass!;
return SafeArea(
child: Scaffold(
backgroundColor: Colors.white,
body: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CommonAppBar(isWhiteLogo: false, isProfilePage: false),
SizedBox(height: 10.h),
backWidget(context, "Back"),
SizedBox(height: 20.h),
SizedBox(height: 10.h),
Text(
"Scan this at the site of\nattraction",
textAlign: TextAlign.center,
style: GoogleFonts.poppins(
fontSize: 13.sp,
color: Colors.black87,
),
),
SizedBox(height: 20.h),
/// ♻️ Reusable QR Container Component
QrContainerWidget(
qrImagePath: "assets/images/qr_image.png",
cityCardTitle: "Melbourne CityCards",
qrCode: "IYFHHVN254ADSD",
cardType: pass.title,
),
SizedBox(height: 24.h),
/// 🎟 Card details section
Container(
padding: EdgeInsets.symmetric(
vertical: 10,
horizontal: 40,
),
decoration: BoxDecoration(
color: pass.title.toLowerCase() == "unlimited card"
? const Color(0xffF95F62).withOpacity(0.1)
: const Color(0xffF95FAF).withOpacity(0.1),
borderRadius: BorderRadius.circular(25.r),
border: Border.all(
color: pass.title.toLowerCase() == "unlimited card"
? const Color(0xffF95F62)
: const Color(0xffF95FAF),
),
),
child: Text(
pass.title,
style: GoogleFonts.poppins(
fontSize: 16.sp,
color: const Color(0xffFF5A5F),
fontWeight: FontWeight.w500,
),
),
),
SizedBox(height: 6.h),
Text(
"Adults-${pass.adults} • Kids-${pass.kids}${pass.duration}",
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Color(0xff212121),
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 4.h),
Text(
"Valid Till: ${pass.validity}",
style: GoogleFonts.poppins(
fontSize: 12.sp,
color: Color(0xff212121),
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 28.h),
Align(
alignment: Alignment.centerLeft,
child: Text(
"Learn about policies",
style: GoogleFonts.poppins(
color: Colors.black,
fontSize: 12.sp,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
),
),
),
SizedBox(height: 24.h),
/// 🔘 Buttons
Column(
children: [
actionButton(
label: "View All Attractions",
onPressed: () {
Navigator.of(context).pushNamed(RouteConstants.attractionsPage, arguments: "qrPass");
},
),
SizedBox(height: 12.h),
actionButton(
label: "View All Available Offers",
onPressed: () {},
),
],
),
],
),
),
),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}
}

View File

@@ -0,0 +1,40 @@
import "package:flutter/material.dart";
import "package:flutter_screenutil/flutter_screenutil.dart";
import "package:google_fonts/google_fonts.dart";
Widget actionButton({
required String label,
required VoidCallback onPressed,
}) {
return GestureDetector(
onTap: onPressed,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 14.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.r),
color: const Color(0xffFFF5F5),
border: Border.all(color: const Color(0xffF5C2C2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: GoogleFonts.poppins(
color: Colors.black87,
fontWeight: FontWeight.w500,
fontSize: 13.sp,
),
),
SizedBox(width: 4.w),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: Colors.black54,
),
],
),
),
);
}

View File

@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
class PassTicketCard extends StatelessWidget {
final dynamic pass;
const PassTicketCard({super.key, required this.pass});
@override
Widget build(BuildContext context) {
// Dimensions tuned to your screenshot
final double cardWidth = MediaQuery.of(context).size.width - 32.w;
final double topSectionHeight = 105.h; // where dotted line sits
final double bottomSectionHeight = 50.h;
final double cardHeight = topSectionHeight + bottomSectionHeight;
return SizedBox(
width: cardWidth,
child: CustomPaint(
// paints white background, border, corner radius, side cuts, shadow, and divider dots
painter: _TicketBackgroundPainter(
cornerRadius: 16.r,
notchRadius: 9.r,
dividerY: topSectionHeight,
borderColor: Colors.white,
shadowColor: Colors.black.withOpacity(0.08),
),
child: ClipPath(
// actual clipping so child content never bleeds outside the shape
clipper: _TicketClipper(
cornerRadius: 16.r,
notchRadius: 9.r,
dividerY: topSectionHeight,
),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
child: Column(
children: [
// ---------- TOP SECTION ----------
SizedBox(
height: topSectionHeight - 12.h, // keep space for the dots line
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// thumbnail
ClipRRect(
borderRadius: BorderRadius.circular(10.r),
child: Image.asset(
pass.imageUrl,
height: 80.h,
width: 80.w,
fit: BoxFit.cover,
),
),
SizedBox(width: 10.w),
// details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (pass.isActive)
Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: const Color(0xff439F6E),
borderRadius: BorderRadius.circular(30.r),
),
child: Text(
"Active",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 10.sp,
fontWeight: FontWeight.w400,
),
),
),
SizedBox(width: 8.w),
Text(
pass.duration, // "2 Days"
style: GoogleFonts.poppins(
color: Colors.black87,
fontSize: 12.sp,
),
),
],
),
SizedBox(height: 10.h),
Text(
pass.title,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 18.sp,
height: 1.1,
),
),
SizedBox(height: 4.h),
Text(
"Adults-${pass.adults} • Kids-${pass.kids}",
style: GoogleFonts.poppins(
color: Colors.black54,
fontSize: 11.sp,
),
),
],
),
),
// QR chip
CircleAvatar(
radius: 20.r,
backgroundColor: Color(0xffFEE7E7),
child: Image.asset(
"assets/images/qr_image.png",
scale: 6,
),
)
],
),
),
// space exactly where the dotted line is painted by the painter
SizedBox(height: 15.h),
// ---------- BOTTOM SECTION ----------
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Valid Till: ${pass.validity}",
style: GoogleFonts.poppins(
fontSize: 11.sp,
color: Colors.black,
fontWeight: FontWeight.w400
),
),
Text(
pass.city, // "Melbourne"
style: GoogleFonts.poppins(
fontWeight: FontWeight.w500,
fontSize: 13.sp,
),
),
],
),
),
],
),
),
),
),
);
}
}
/// Clips the ticket with rounded corners and 2 side “cuts” centered at dividerY
class _TicketClipper extends CustomClipper<Path> {
final double cornerRadius;
final double notchRadius;
final double dividerY;
_TicketClipper({
required this.cornerRadius,
required this.notchRadius,
required this.dividerY,
});
@override
Path getClip(Size size) {
final rrectPath = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
Radius.circular(cornerRadius),
));
final cuts = Path()
..addOval(Rect.fromCircle(center: Offset(0, dividerY), radius: notchRadius))
..addOval(Rect.fromCircle(center: Offset(size.width, dividerY), radius: notchRadius));
// Rounded-rect MINUS the two circles
return Path.combine(PathOperation.difference, rrectPath, cuts);
}
@override
bool shouldReclip(covariant _TicketClipper old) =>
cornerRadius != old.cornerRadius ||
notchRadius != old.notchRadius ||
dividerY != old.dividerY;
}
/// Paints fill, border, shadow and the dotted perforation line
class _TicketBackgroundPainter extends CustomPainter {
final double cornerRadius;
final double notchRadius;
final double dividerY;
final Color borderColor;
final Color shadowColor;
_TicketBackgroundPainter({
required this.cornerRadius,
required this.notchRadius,
required this.dividerY,
required this.borderColor,
required this.shadowColor,
});
Path _ticketPath(Size size) {
final clipper = _TicketClipper(
cornerRadius: cornerRadius,
notchRadius: notchRadius,
dividerY: dividerY,
);
return clipper.getClip(size);
}
@override
void paint(Canvas canvas, Size size) {
final path = _ticketPath(size);
// Realistic layered shadow
canvas.save();
canvas.translate(0, 2); // tiny downward offset for depth
final shadowPaint = Paint()
..color = Colors.black.withOpacity(0.10)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6);
canvas.drawPath(path, shadowPaint);
canvas.restore();
// Subtle ambient shadow (light spread around)
final ambientShadowPaint = Paint()
..color = Colors.black.withOpacity(0.04)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
canvas.drawPath(path, ambientShadowPaint);
// Fill background
final fillPaint = Paint()
..style = PaintingStyle.fill
..color = const Color(0xffFFFBFB);
canvas.drawPath(path, fillPaint);
// Border stroke
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = const Color(0xffE5E5E5);
canvas.drawPath(path, strokePaint);
// 🔹 Dotted perforation line
final dashPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = const Color(0xff787878);
const double dashWidth = 4;
const double dashSpace = 4;
double startX = 12;
final double endX = size.width - 12;
while (startX < endX) {
final double currentEnd = (startX + dashWidth).clamp(0, endX);
canvas.drawLine(
Offset(startX, dividerY),
Offset(currentEnd, dividerY),
dashPaint,
);
startX += dashWidth + dashSpace;
}
}
@override
bool shouldRepaint(covariant _TicketBackgroundPainter oldDelegate) {
return cornerRadius != oldDelegate.cornerRadius ||
notchRadius != oldDelegate.notchRadius ||
dividerY != oldDelegate.dividerY ||
borderColor != oldDelegate.borderColor ||
shadowColor != oldDelegate.shadowColor;
}
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
class QrContainerWidget extends StatelessWidget {
final String qrImagePath;
final String cityCardTitle;
final String qrCode;
final String cardType;
const QrContainerWidget({
super.key,
required this.qrImagePath,
required this.cityCardTitle,
required this.qrCode,
required this.cardType
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: 380.h,
margin: EdgeInsets.symmetric(horizontal: 20.w),
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 20.h),
decoration: BoxDecoration(
color: const Color(0xffF95F62).withOpacity(0.1),
borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: cardType.toLowerCase() == "unlimited card" ? const Color(0xffF95F62) : const Color(0xffF95FAF), width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
cityCardTitle,
style: GoogleFonts.poppins(
fontSize: 18.sp,
color: cardType.toLowerCase() == "unlimited card" ? const Color(0xffF95F62) : const Color(0xffF95FAF),
fontWeight: FontWeight.w500,
),
),
Image.asset(
qrImagePath,
height: 250.h,
width: 250.w,
fit: BoxFit.contain,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
qrCode,
style: GoogleFonts.poppins(
fontWeight: FontWeight.w500,
fontSize: 15.sp,
color: Color(0xff212121)
),
),
SizedBox(width: 6.w),
GestureDetector(
onTap: () async {
await Clipboard.setData(ClipboardData(text: qrCode));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Code copied to clipboard!",
style: GoogleFonts.poppins(
color: Colors.white,
fontSize: 12.sp,
),
),
backgroundColor: Colors.black87,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
},
child: const Icon(Icons.copy, size: 18, color: Color(0xff212121)),
),
],
),
],
),
);
}
}