From c0d1bee77370b52eb48e4e9e32d4a7b308e88281 Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Tue, 1 Jul 2025 17:02:51 +0330 Subject: [PATCH] Reserve Page --- assets/icons/arrow-left.svg | 4 + assets/icons/card-pos.svg | 7 + assets/icons/ticket-discount.svg | 6 + lib/core/config/app_colors.dart | 2 + lib/core/gen/assets.gen.dart | 13 + .../models/datasources/offer_data_source.dart | 7 +- lib/data/models/offer_model.dart | 9 +- lib/data/models/product_model.dart | 17 + .../pages/product_detail_page.dart | 78 ++-- .../pages/reservation_details_screen.dart | 358 ++++++++++++++++++ pubspec.lock | 16 + pubspec.yaml | 1 + 12 files changed, 479 insertions(+), 39 deletions(-) create mode 100644 assets/icons/arrow-left.svg create mode 100644 assets/icons/card-pos.svg create mode 100644 assets/icons/ticket-discount.svg create mode 100644 lib/data/models/product_model.dart create mode 100644 lib/presentation/pages/reservation_details_screen.dart diff --git a/assets/icons/arrow-left.svg b/assets/icons/arrow-left.svg new file mode 100644 index 0000000..4c58540 --- /dev/null +++ b/assets/icons/arrow-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/card-pos.svg b/assets/icons/card-pos.svg new file mode 100644 index 0000000..8abb308 --- /dev/null +++ b/assets/icons/card-pos.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/ticket-discount.svg b/assets/icons/ticket-discount.svg new file mode 100644 index 0000000..e9535d4 --- /dev/null +++ b/assets/icons/ticket-discount.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/core/config/app_colors.dart b/lib/core/config/app_colors.dart index 60660ff..6caa5b8 100644 --- a/lib/core/config/app_colors.dart +++ b/lib/core/config/app_colors.dart @@ -14,4 +14,6 @@ class AppColors { static const Color selectedImg = Color.fromARGB(255, 76, 175, 80); static const Color singleOfferType = Color.fromARGB(255, 244, 67, 54); static const Color countdown = Color.fromARGB(255, 54, 124, 57); + static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251); + static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28); } \ No newline at end of file diff --git a/lib/core/gen/assets.gen.dart b/lib/core/gen/assets.gen.dart index 20174c1..af834ff 100644 --- a/lib/core/gen/assets.gen.dart +++ b/lib/core/gen/assets.gen.dart @@ -75,12 +75,18 @@ class $AssetsIconsGen { /// File path: assets/icons/arayesh.svg SvgGenImage get arayesh => const SvgGenImage('assets/icons/arayesh.svg'); + /// File path: assets/icons/arrow-left.svg + SvgGenImage get arrowLeft => const SvgGenImage('assets/icons/arrow-left.svg'); + /// File path: assets/icons/back.svg SvgGenImage get back => const SvgGenImage('assets/icons/back.svg'); /// File path: assets/icons/backArrow.svg SvgGenImage get backArrow => const SvgGenImage('assets/icons/backArrow.svg'); + /// File path: assets/icons/card-pos.svg + SvgGenImage get cardPos => const SvgGenImage('assets/icons/card-pos.svg'); + /// File path: assets/icons/cinama.svg SvgGenImage get cinama => const SvgGenImage('assets/icons/cinama.svg'); @@ -180,6 +186,10 @@ class $AssetsIconsGen { /// File path: assets/icons/tickPb.svg SvgGenImage get tickPb => const SvgGenImage('assets/icons/tickPb.svg'); + /// File path: assets/icons/ticket-discount.svg + SvgGenImage get ticketDiscount => + const SvgGenImage('assets/icons/ticket-discount.svg'); + /// File path: assets/icons/timer-pause.svg SvgGenImage get timerPause => const SvgGenImage('assets/icons/timer-pause.svg'); @@ -212,8 +222,10 @@ class $AssetsIconsGen { vector, addImg, arayesh, + arrowLeft, back, backArrow, + cardPos, cinama, clock, clockProduct, @@ -244,6 +256,7 @@ class $AssetsIconsGen { tickCircle, tickSquare, tickPb, + ticketDiscount, timerPause, volumeHigh, warning2, diff --git a/lib/data/models/datasources/offer_data_source.dart b/lib/data/models/datasources/offer_data_source.dart index 2d5eb31..6d0db42 100644 --- a/lib/data/models/datasources/offer_data_source.dart +++ b/lib/data/models/datasources/offer_data_source.dart @@ -91,7 +91,7 @@ class MockOfferDataSource implements OfferDataSource { publishedAt: DateTime.now().subtract(const Duration(days: 5)), ), ], - + qrCodeData: 'PROXIBUY-OFFER-ID-1', ), OfferModel( id: '2', @@ -174,7 +174,7 @@ class MockOfferDataSource implements OfferDataSource { publishedAt: DateTime.now().subtract(const Duration(days: 5)), ), ], - + qrCodeData: 'PROXIBUY-OFFER-ID-1', ), OfferModel( id: '3', @@ -257,6 +257,7 @@ class MockOfferDataSource implements OfferDataSource { publishedAt: DateTime.now().subtract(const Duration(days: 5)), ), ], + qrCodeData: 'PROXIBUY-OFFER-ID-1', ), OfferModel( id: '4', @@ -339,7 +340,7 @@ class MockOfferDataSource implements OfferDataSource { publishedAt: DateTime.now().subtract(const Duration(days: 5)), ), ], - + qrCodeData: 'PROXIBUY-OFFER-ID-1', ), ]; diff --git a/lib/data/models/offer_model.dart b/lib/data/models/offer_model.dart index 703f5e8..45aa9ad 100644 --- a/lib/data/models/offer_model.dart +++ b/lib/data/models/offer_model.dart @@ -24,7 +24,8 @@ class OfferModel extends Equatable { final double finalPrice; final List features; final DiscountInfoModel? discountInfo; - final List comments; // <-- این خط اضافه شد + final List comments; + final String qrCodeData; const OfferModel({ required this.id, @@ -47,7 +48,8 @@ class OfferModel extends Equatable { required this.finalPrice, this.features = const [], this.discountInfo, - this.comments = const [], // <-- این خط اضافه شد + this.comments = const [], + required this.qrCodeData, }); String get coverImageUrl => @@ -64,7 +66,8 @@ class OfferModel extends Equatable { longitude, features, discountInfo, - comments, // <-- این خط اضافه شد + comments, + qrCodeData ]; String get distanceAsString { diff --git a/lib/data/models/product_model.dart b/lib/data/models/product_model.dart new file mode 100644 index 0000000..c76e519 --- /dev/null +++ b/lib/data/models/product_model.dart @@ -0,0 +1,17 @@ +class Product { + final String category; + final String name; + final String imageUrl; + final double originalPrice; + final double discountPercentage; + + Product({ + required this.category, + required this.name, + required this.imageUrl, + required this.originalPrice, + required this.discountPercentage, + }); + + double get finalPrice => originalPrice * (1 - discountPercentage / 100); +} \ No newline at end of file diff --git a/lib/presentation/pages/product_detail_page.dart b/lib/presentation/pages/product_detail_page.dart index 1bd1331..79ae772 100644 --- a/lib/presentation/pages/product_detail_page.dart +++ b/lib/presentation/pages/product_detail_page.dart @@ -7,6 +7,7 @@ import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/core/gen/assets.gen.dart'; import 'package:proxibuy/data/models/offer_model.dart'; import 'package:proxibuy/data/repositories/offer_repository.dart'; +import 'package:proxibuy/presentation/pages/reservation_details_screen.dart'; import 'package:proxibuy/presentation/product_detail/bloc/product_detail_bloc.dart'; import 'package:proxibuy/presentation/product_detail/bloc/product_detail_event.dart'; import 'package:proxibuy/presentation/product_detail/bloc/product_detail_state.dart'; @@ -54,41 +55,52 @@ class ProductDetailPage extends StatelessWidget { bottom: 30, left: 24, right: 24, - child: ElevatedButton( - onPressed: () { - print("دکمه فشرده شد!"); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - elevation: 5, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset(Assets.icons.receiptDisscount.path), - const SizedBox(width: 8), - const Text( - 'رزرو تخفیف', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.normal, - color: Colors.black, + // دکمه را در یک BlocBuilder قرار می‌دهیم تا به state دسترسی داشته باشد + child: BlocBuilder( + builder: (context, state) { + // دکمه فقط زمانی نمایش داده می‌شود که اطلاعات محصول با موفقیت لود شده باشد + if (state is ProductDetailLoadSuccess) { + return ElevatedButton( + onPressed: () { + // با کلیک روی دکمه، به صفحه تایید رزرو منتقل می‌شویم + // و اطلاعات محصول را به آن پاس می‌دهیم + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReservationConfirmationPage(offer: state.offer), ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.confirm, // رنگ دکمه را به سبز تغییر دادم تا با مفهوم رزرو همخوانی داشته باشد + elevation: 5, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), // گردی بیشتر برای زیبایی ), - ], - ), - ) - .animate() - .fadeIn( - delay: 200.ms, - duration: 400.ms, - curve: Curves.easeOut, - ) - .slideY(begin: 2, duration: 500.ms, curve: Curves.easeOut), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset(Assets.icons.receiptDisscount.path,), + const SizedBox(width: 12), + const Text( + 'رزرو تخفیف', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.normal, + color: Colors.black, + ), + ), + ], + ), + ).animate() + .fadeIn(delay: 200.ms, duration: 400.ms, curve: Curves.easeOut) + .slideY(begin: 2, duration: 500.ms, curve: Curves.easeOut); + } + // اگر اطلاعات در حال لود شدن باشد، چیزی نمایش داده نمی‌شود + return const SizedBox.shrink(); + }, + ), ), ], ), diff --git a/lib/presentation/pages/reservation_details_screen.dart b/lib/presentation/pages/reservation_details_screen.dart new file mode 100644 index 0000000..66ca24a --- /dev/null +++ b/lib/presentation/pages/reservation_details_screen.dart @@ -0,0 +1,358 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:proxibuy/core/config/app_colors.dart'; +import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/data/models/offer_model.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +class ReservationConfirmationPage extends StatefulWidget { + final OfferModel offer; + + const ReservationConfirmationPage({super.key, required this.offer}); + + @override + State createState() => + _ReservationConfirmationPageState(); +} + +class _ReservationConfirmationPageState extends State { + Timer? _timer; + Duration _remaining = Duration.zero; + + @override + void initState() { + super.initState(); + _calculateRemainingTime(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + _calculateRemainingTime(); + }); + } + + void _calculateRemainingTime() { + final now = DateTime.now(); + if (widget.offer.expiryTime.isAfter(now)) { + if (mounted) { + setState(() { + _remaining = widget.offer.expiryTime.difference(now); + }); + } + } else { + if (mounted) { + setState(() { + _remaining = Duration.zero; + }); + } + _timer?.cancel(); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + backgroundColor: Colors.grey[50], + appBar: _buildCustomAppBar(context), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 32.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ✅ انیمیشن برای عنوان اصلی + Text( + 'تخفیف ${widget.offer.discountType} رزرو شد!', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ).animate().fadeIn(delay: 300.ms, duration: 500.ms).slideY(begin: -0.2, end: 0), + + const SizedBox(height: 8), + // ✅ انیمیشن برای خط جداکننده + const Divider(thickness: 1.5) + .animate() + .fadeIn(delay: 400.ms) + .scaleX(begin: 0, duration: 600.ms, curve: Curves.easeInOut), + + const SizedBox(height: 18), + _buildOfferDetailsCard() + .animate() + .fadeIn(delay: 600.ms, duration: 500.ms) + .slideX(begin: 0.5, end: 0, curve: Curves.easeOutCubic), + + const SizedBox(height: 18), + _buildTimerCard() + .animate() + .fadeIn(delay: 800.ms, duration: 500.ms) + .scale(begin: const Offset(0.8, 0.8), curve: Curves.easeOutBack), + + const SizedBox(height: 18), + _buildQrCodeCard() + .animate() + .fadeIn(delay: 1000.ms, duration: 500.ms) + .flipV(begin: -0.5, end: 0, curve: Curves.easeOut), + ], + ), + ), + ), + ); + } + + PreferredSizeWidget _buildCustomAppBar(BuildContext context) { + return PreferredSize( + preferredSize: const Size.fromHeight(70.0), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(15), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + SizedBox(height: 15), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Row( + children: [ + const Text( + 'رزرو شده', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.normal, + fontSize: 16, + ), + ), + IconButton( + icon: SvgPicture.asset(Assets.icons.arrowLeft.path), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildOfferDetailsCard() { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: widget.offer.coverImageUrl, + width: 110, + height: 110, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // جزئیات متنی + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text( + widget.offer.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.normal, + color: AppColors.hint, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + SvgPicture.asset(Assets.icons.ticketDiscount.path), + const SizedBox(width: 6), + Text( + '(${widget.offer.discount})', + style: const TextStyle( + fontSize: 16, + color: AppColors.singleOfferType, + fontWeight: FontWeight.normal, + ), + ), + const SizedBox(width: 6), + Text( + widget.offer.originalPrice.toStringAsFixed(0), + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + decoration: TextDecoration.lineThrough, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + SvgPicture.asset(Assets.icons.cardPos.path,height: 22,color: Color.fromARGB(255, 157, 157, 155),), + SizedBox(width: 6,), + Text( + '${widget.offer.finalPrice.toStringAsFixed(0)} تومان', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.normal, + color: AppColors.singleOfferType, + ), + ), + ], + ), + const SizedBox(height: 10), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTimerCard() { + if (_remaining.inSeconds <= 0) { + return const Center( + child: Text( + 'مهلت این تخفیف به پایان رسیده است', + style: TextStyle(color: AppColors.singleOfferType, fontSize: 16), + ), + ); + } + + String days = _remaining.inDays.toString(); + String hours = (_remaining.inHours % 24).toString(); + String minutes = (_remaining.inMinutes % 60).toString(); + String seconds = (_remaining.inSeconds % 60).toString(); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: BoxDecoration( + color: Color.fromARGB(255, 246, 246, 246), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Column( + children: [ + Text("مدت",style: TextStyle(color: AppColors.expiryReserve,fontSize: 15),), + SizedBox(height: 7,), + Text("اعتبار",style: TextStyle(color: AppColors.expiryReserve,fontSize: 15),), + ], + ), + SizedBox(width: 25,), + _buildTimeBlock(seconds, 'ثانیه'), + SizedBox(width: 20,), + _buildTimeBlock(minutes, 'دقیقه'), + SizedBox(width: 20,), + _buildTimeBlock(hours, 'ساعت'), + SizedBox(width: 20,), + if (_remaining.inDays > 0) _buildTimeBlock(days, 'روز'), + ], + ), + SizedBox(height: 20 ,), + Text("لطفا QR Code زیر رو به فروشنده نشون بده.",style: TextStyle(fontSize: 15),) + ], + ), + ); + } + + Widget _buildTimeBlock(String value, String label) { + return Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3), + decoration: BoxDecoration( + border: Border.all(color: AppColors.countdownBorderRserve, width: 1.5), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 20, + fontFamily: 'Dana', + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontSize: 12, + fontFamily: 'Dana', + color: Colors.black, + ), + ), + ], + ), + ), + ), + ], + ); + } + Widget _buildQrCodeCard() { + return Center( + child: Container( + width: 500, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Color.fromARGB(255, 246, 246, 246), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: QrImageView( + data: widget.offer.qrCodeData, + version: QrVersions.auto, + size: 280.0, + ), + ), + SizedBox(height: 10,), + Text(widget.offer.qrCodeData,style: TextStyle(fontWeight: FontWeight.bold,fontSize: 20),) + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 406ebb3..3eb44e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -829,6 +829,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 03e2c72..28f555c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: flutter_animate: ^4.5.2 maps_launcher: ^3.0.0+1 slide_countdown: ^2.0.2 + qr_flutter: ^4.1.0 dev_dependencies: flutter_test: