diff --git a/assets/images/empty_buy_ pass.png b/assets/images/empty_buy_ pass.png new file mode 100644 index 0000000..87e4756 Binary files /dev/null and b/assets/images/empty_buy_ pass.png differ diff --git a/assets/images/qr_image.png b/assets/images/qr_image.png new file mode 100644 index 0000000..4a3a751 Binary files /dev/null and b/assets/images/qr_image.png differ diff --git a/lib/attractions/blocs/attractions_bloc.dart b/lib/attractions/blocs/attractions_bloc.dart index 7805d9f..ca8e7ec 100644 --- a/lib/attractions/blocs/attractions_bloc.dart +++ b/lib/attractions/blocs/attractions_bloc.dart @@ -14,6 +14,11 @@ class AttractionsBloc extends Bloc { emit(AttractionsLoaded(attractions)); }); + on((event, emit) { + final attractions = repository.fetchMyPassAttraction(); + emit(AttractionsLoaded(attractions)); + }); + on((event, emit) { if (state is AttractionsLoaded) { final currentState = state as AttractionsLoaded; diff --git a/lib/attractions/blocs/attractions_event.dart b/lib/attractions/blocs/attractions_event.dart index 4db5651..afa0000 100644 --- a/lib/attractions/blocs/attractions_event.dart +++ b/lib/attractions/blocs/attractions_event.dart @@ -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); diff --git a/lib/attractions/models/attraction_model.dart b/lib/attractions/models/attraction_model.dart index 7fcc1dd..4c37a29 100644 --- a/lib/attractions/models/attraction_model.dart +++ b/lib/attractions/models/attraction_model.dart @@ -4,6 +4,8 @@ class Attraction { final String price; final String image; final List 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 }); } diff --git a/lib/attractions/repository/attractions_repository.dart b/lib/attractions/repository/attractions_repository.dart index 55e0f7d..dba0114 100644 --- a/lib/attractions/repository/attractions_repository.dart +++ b/lib/attractions/repository/attractions_repository.dart @@ -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 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... ", ), ]; } diff --git a/lib/attractions/views/attractions_page_view.dart b/lib/attractions/views/attractions_page_view.dart index 7c4c3f6..9621fcd 100644 --- a/lib/attractions/views/attractions_page_view.dart +++ b/lib/attractions/views/attractions_page_view.dart @@ -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( builder: (context, state) { final bloc = context.read(); @@ -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 diff --git a/lib/attractions/widget/attraction_card.dart b/lib/attractions/widget/attraction_card.dart index d6b2a9b..14b56e1 100644 --- a/lib/attractions/widget/attraction_card.dart +++ b/lib/attractions/widget/attraction_card.dart @@ -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,91 +9,133 @@ class AttractionCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: const Color(0xffFDCDCE)), - borderRadius: BorderRadius.circular(15), - color: Color(0xffFFF5F5), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - attraction.image, - height: 94, - width: 94, - fit: BoxFit.cover, + 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( + border: Border.all(color: const Color(0xffFDCDCE)), + borderRadius: BorderRadius.circular(15), + color: Color(0xffFFF5F5), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + attraction.image, + height: 94, + width: 94, + fit: BoxFit.cover, + ), ), - ), - const SizedBox(width: 10), - 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))), - const SizedBox(height: 6), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: "from ${attraction.price}", - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - const TextSpan( - text: "/person", - style: - TextStyle(fontSize: 10, color: Colors.black, fontWeight: FontWeight.w400,), - ), - ], + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + attraction.title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 6, - children: attraction.tags - .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), - border: Border.all( - color: tag == "Flexi Card" - ? const Color(0xffF95FAF) - : const Color(0xffF95F62), - ), - borderRadius: BorderRadius.circular(20), + const SizedBox(height: 6), + Text( + attraction.location, + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w400, + color: Color(0xff464646), ), - child: Text( - tag, - style: GoogleFonts.poppins( - fontSize: 11, - color: Color(0xff1A1A1A), - fontWeight: FontWeight.w400, - ), + ), + const SizedBox(height: 6), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: "from ${attraction.price}", + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + const TextSpan( + text: "/person", + style: TextStyle( + fontSize: 10, + color: Colors.black, + fontWeight: FontWeight.w400, + ), + ), + ], ), - )) - .toList(), - ) -, - ], + ), + const SizedBox(height: 6), + attraction.isBookingRequired == false + ? Wrap( + spacing: 6, + children: attraction.tags + .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), + border: Border.all( + color: tag == "Flexi 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, + ), + 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, + ), + ), + ), + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 06af037..246ad54 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -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: (_) { diff --git a/lib/core/inside_bottom_navigator.dart b/lib/core/inside_bottom_navigator.dart index cba13a3..c7256ba 100644 --- a/lib/core/inside_bottom_navigator.dart +++ b/lib/core/inside_bottom_navigator.dart @@ -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(context); + return BlocProvider.value( + value: previousBloc, + child: const QrPassView(), + ); + }, + ); + default: return MaterialPageRoute( builder: (_) => const Scaffold( diff --git a/lib/core/route_constants.dart b/lib/core/route_constants.dart index 111469e..1ff1ba5 100644 --- a/lib/core/route_constants.dart +++ b/lib/core/route_constants.dart @@ -42,4 +42,6 @@ class RouteConstants { /************************** My card page ***************************************/ static const String cartPage = '/cartPage'; + + static const String qrPage = '/qrPage'; } diff --git a/lib/home/views/home_page_view.dart b/lib/home/views/home_page_view.dart index 6ae66db..2309347 100644 --- a/lib/home/views/home_page_view.dart +++ b/lib/home/views/home_page_view.dart @@ -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 { 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]), ], ), diff --git a/lib/home/views/registered_user_home_page.dart b/lib/home/views/registered_user_home_page.dart index c037d34..5d40b71 100644 --- a/lib/home/views/registered_user_home_page.dart +++ b/lib/home/views/registered_user_home_page.dart @@ -127,7 +127,7 @@ class _RegisteredUserHomePageState extends State { ), InkWell( onTap: (){ - Navigator.of(context).pushNamed(RouteConstants.attractionsPage); + Navigator.of(context).pushNamed(RouteConstants.attractionsPage, arguments: "home"); }, child: Text("View all", style: TextStyle( diff --git a/lib/main.dart b/lib/main.dart index 35341e4..216c347 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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,13 +31,20 @@ class MyApp extends StatelessWidget { return ScreenUtilInit( designSize: const Size(390, 844), builder: (context, child) { - return MaterialApp( - onGenerateRoute: _appRouter.onGenerateRoute, - debugShowCheckedModeBanner: false, - title: 'City Cards', - theme: ThemeData( - textTheme: GoogleFonts.poppinsTextTheme( - Theme.of(context).textTheme, + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => MyPassBloc()..add(LoadMyPasses()), + ), + ], + child: MaterialApp( + onGenerateRoute: _appRouter.onGenerateRoute, + debugShowCheckedModeBanner: false, + title: 'City Cards', + theme: ThemeData( + textTheme: GoogleFonts.poppinsTextTheme( + Theme.of(context).textTheme, + ), ), ), ); diff --git a/lib/my_pass/blocs/my_pass_bloc.dart b/lib/my_pass/blocs/my_pass_bloc.dart new file mode 100644 index 0000000..1559b83 --- /dev/null +++ b/lib/my_pass/blocs/my_pass_bloc.dart @@ -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 passes; + final MyPassModel? selectedPass; + MyPassLoaded(this.passes, this.selectedPass); +} + +class MyPassBloc extends Bloc { + MyPassBloc() : super(MyPassLoading()) { + + on((event, emit) async { + await Future.delayed(const Duration(milliseconds: 500)); + + final List 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((event, emit) { + if (state is MyPassLoaded) { + final current = state as MyPassLoaded; + emit(MyPassLoaded(current.passes, event.selectedPass)); + } + }); + } +} diff --git a/lib/my_pass/models/my_pass_model.dart b/lib/my_pass/models/my_pass_model.dart new file mode 100644 index 0000000..af09385 --- /dev/null +++ b/lib/my_pass/models/my_pass_model.dart @@ -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, + }); +} diff --git a/lib/my_pass/views/my_pass_page_view.dart b/lib/my_pass/views/my_pass_page_view.dart new file mode 100644 index 0000000..b4b5c1e --- /dev/null +++ b/lib/my_pass/views/my_pass_page_view.dart @@ -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( + 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 Don’t 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().add(SelectPass(pass)); + Navigator.of( + context, + ).pushNamed(RouteConstants.qrPage); + }, + child: PassTicketCard(pass: pass), + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/my_pass/views/qr_pass_page_view.dart b/lib/my_pass/views/qr_pass_page_view.dart new file mode 100644 index 0000000..9404b73 --- /dev/null +++ b/lib/my_pass/views/qr_pass_page_view.dart @@ -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( + 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()); + } + }, + ); + } +} diff --git a/lib/my_pass/widgets/action_button_widget.dart b/lib/my_pass/widgets/action_button_widget.dart new file mode 100644 index 0000000..6bbef94 --- /dev/null +++ b/lib/my_pass/widgets/action_button_widget.dart @@ -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, + ), + ], + ), + ), + ); +} \ No newline at end of file diff --git a/lib/my_pass/widgets/pass_widget.dart b/lib/my_pass/widgets/pass_widget.dart new file mode 100644 index 0000000..ace5562 --- /dev/null +++ b/lib/my_pass/widgets/pass_widget.dart @@ -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 { + 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; + } +} diff --git a/lib/my_pass/widgets/qr_container_widget.dart b/lib/my_pass/widgets/qr_container_widget.dart new file mode 100644 index 0000000..74596f7 --- /dev/null +++ b/lib/my_pass/widgets/qr_container_widget.dart @@ -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)), + ), + ], + ), + ], + ), + ); + } +}