diff --git a/assets/icons/Line 2.svg b/assets/icons/Line 2.svg new file mode 100644 index 0000000..1f51b1d --- /dev/null +++ b/assets/icons/Line 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/receipt-disscount.svg b/assets/icons/receipt-disscount.svg new file mode 100644 index 0000000..187577f --- /dev/null +++ b/assets/icons/receipt-disscount.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/star fill.svg b/assets/icons/star fill.svg new file mode 100644 index 0000000..7ec9167 --- /dev/null +++ b/assets/icons/star fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/star half.svg b/assets/icons/star half.svg new file mode 100644 index 0000000..86f760e --- /dev/null +++ b/assets/icons/star half.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/star2.svg b/assets/icons/star2.svg new file mode 100644 index 0000000..981d528 --- /dev/null +++ b/assets/icons/star2.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/tick-square.svg b/assets/icons/tick-square.svg new file mode 100644 index 0000000..36d629c --- /dev/null +++ b/assets/icons/tick-square.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/warning-2.svg b/assets/icons/warning-2.svg new file mode 100644 index 0000000..7503e0a --- /dev/null +++ b/assets/icons/warning-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/core/gen/assets.gen.dart b/lib/core/gen/assets.gen.dart index 20937d4..20174c1 100644 --- a/lib/core/gen/assets.gen.dart +++ b/lib/core/gen/assets.gen.dart @@ -37,6 +37,9 @@ class $AssetsIconsGen { /// File path: assets/icons/Google svg.svg SvgGenImage get googleSvg => const SvgGenImage('assets/icons/Google svg.svg'); + /// File path: assets/icons/Line 2.svg + SvgGenImage get line2 => const SvgGenImage('assets/icons/Line 2.svg'); + /// File path: assets/icons/LogoWithName.svg SvgGenImage get logoWithName => const SvgGenImage('assets/icons/LogoWithName.svg'); @@ -127,6 +130,10 @@ class $AssetsIconsGen { /// File path: assets/icons/pooshak.svg SvgGenImage get pooshak => const SvgGenImage('assets/icons/pooshak.svg'); + /// File path: assets/icons/receipt-disscount.svg + SvgGenImage get receiptDisscount => + const SvgGenImage('assets/icons/receipt-disscount.svg'); + /// File path: assets/icons/resturan.svg SvgGenImage get resturan => const SvgGenImage('assets/icons/resturan.svg'); @@ -144,9 +151,18 @@ class $AssetsIconsGen { SvgGenImage get shoppingCart => const SvgGenImage('assets/icons/shopping-cart.svg'); + /// File path: assets/icons/star fill.svg + SvgGenImage get starFill => const SvgGenImage('assets/icons/star fill.svg'); + + /// File path: assets/icons/star half.svg + SvgGenImage get starHalf => const SvgGenImage('assets/icons/star half.svg'); + /// File path: assets/icons/star.svg SvgGenImage get star => const SvgGenImage('assets/icons/star.svg'); + /// File path: assets/icons/star2.svg + SvgGenImage get star2 => const SvgGenImage('assets/icons/star2.svg'); + /// File path: assets/icons/tala.svg SvgGenImage get tala => const SvgGenImage('assets/icons/tala.svg'); @@ -157,6 +173,10 @@ class $AssetsIconsGen { SvgGenImage get tickCircle => const SvgGenImage('assets/icons/tick-circle.svg'); + /// File path: assets/icons/tick-square.svg + SvgGenImage get tickSquare => + const SvgGenImage('assets/icons/tick-square.svg'); + /// File path: assets/icons/tickPb.svg SvgGenImage get tickPb => const SvgGenImage('assets/icons/tickPb.svg'); @@ -168,6 +188,9 @@ class $AssetsIconsGen { SvgGenImage get volumeHigh => const SvgGenImage('assets/icons/volume-high.svg'); + /// File path: assets/icons/warning-2.svg + SvgGenImage get warning2 => const SvgGenImage('assets/icons/warning-2.svg'); + /// List of all assets List get values => [ arrowRight2, @@ -177,6 +200,7 @@ class $AssetsIconsGen { ellipse1, gold, googleSvg, + line2, logoWithName, makeup, mobile, @@ -205,18 +229,24 @@ class $AssetsIconsGen { map, notification, pooshak, + receiptDisscount, resturan, routing, scanBarcode, shop, shoppingCart, + starFill, + starHalf, star, + star2, tala, teria, tickCircle, + tickSquare, tickPb, timerPause, volumeHigh, + warning2, ]; } diff --git a/lib/data/models/comment_model.dart b/lib/data/models/comment_model.dart new file mode 100644 index 0000000..9b16437 --- /dev/null +++ b/lib/data/models/comment_model.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +class CommentModel extends Equatable { + final String id; + final String userName; + final double rating; + final String comment; + final DateTime publishedAt; + final List uploadedImageUrls; + + const CommentModel({ + required this.id, + required this.userName, + required this.rating, + required this.comment, + required this.publishedAt, + this.uploadedImageUrls = const [], + }); + + @override + List get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls]; +} \ No newline at end of file diff --git a/lib/data/models/datasources/offer_data_source.dart b/lib/data/models/datasources/offer_data_source.dart index 6b8b032..2d5eb31 100644 --- a/lib/data/models/datasources/offer_data_source.dart +++ b/lib/data/models/datasources/offer_data_source.dart @@ -1,3 +1,5 @@ +import 'package:proxibuy/data/models/comment_model.dart'; +import 'package:proxibuy/data/models/discount_info_model.dart'; import 'package:proxibuy/data/models/offer_model.dart'; import 'package:proxibuy/data/models/working_hours.dart'; @@ -60,6 +62,36 @@ class MockOfferDataSource implements OfferDataSource { longitude: 51.670, originalPrice: 150000, finalPrice: 120000, + features: [ + "تهیه شده از بهترین و تازه‌ترین مواد اولیه", + "محیطی دنج و مناسب برای قرارهای دوستانه", + "دارای منوی متنوع برای تمام سلیقه‌ها", + ], + discountInfo: const DiscountInfoModel( + name: "رفیق‌بازی", + description: "یه مهمونی دو نفره، یه تخفیف دوتایی؛ پس رفیق‌تو بیار و تخفیف‌تو ببر. دوستای بیشتر، تخفیف بیشتر.", + ), + comments: [ // <-- بخش نظرات اضافه شد + CommentModel( + id: 'c1', + userName: 'سارا رضایی', + rating: 4.5, + comment: 'کیفیت برگر عالی بود و محیط خیلی خوبی داشت. حتما دوباره سر می‌زنم!', + publishedAt: DateTime.now().subtract(const Duration(days: 2)), + uploadedImageUrls: [ + 'https://picsum.photos/seed/user_img1/200/200', + 'https://picsum.photos/seed/user_img2/200/200', + ] + ), + CommentModel( + id: 'c2', + userName: 'pbuser_157', + rating: 4, + comment: 'جای خوب و دنجی بود، فقط یکم شلوغ بود که طبیعیه. در کل راضی بودم.', + publishedAt: DateTime.now().subtract(const Duration(days: 5)), + ), + ], + ), OfferModel( id: '2', @@ -103,7 +135,7 @@ class MockOfferDataSource implements OfferDataSource { Shift(openAt: '۵ عصر', closeAt: '۱۱ شب'), ], ), - WorkingHours(day: 'جمعه', shifts: []), // تعطیل + WorkingHours(day: 'جمعه', shifts: []), ], discountType: 'رفیق‌بازی', isOpen: true, @@ -113,6 +145,36 @@ class MockOfferDataSource implements OfferDataSource { longitude: 51.670, originalPrice: 150000, finalPrice: 120000, + features: [ + "تهیه شده از بهترین و تازه‌ترین مواد اولیه", + "محیطی دنج و مناسب برای قرارهای دوستانه", + "دارای منوی متنوع برای تمام سلیقه‌ها", + ], + discountInfo: const DiscountInfoModel( + name: "رفیق‌بازی", + description: "یه مهمونی دو نفره، یه تخفیف دوتایی؛ پس رفیق‌تو بیار و تخفیف‌تو ببر. دوستای بیشتر، تخفیف بیشتر.", + ), + comments: [ // <-- بخش نظرات اضافه شد + CommentModel( + id: 'c1', + userName: 'سارا رضایی', + rating: 4.5, + comment: 'کیفیت برگر عالی بود و محیط خیلی خوبی داشت. حتما دوباره سر می‌زنم!', + publishedAt: DateTime.now().subtract(const Duration(days: 2)), + uploadedImageUrls: [ + 'https://picsum.photos/seed/user_img1/200/200', + 'https://picsum.photos/seed/user_img2/200/200', + ] + ), + CommentModel( + id: 'c2', + userName: 'علی اکبری', + rating: 4, + comment: 'جای خوب و دنجی بود، فقط یکم شلوغ بود که طبیعیه. در کل راضی بودم.', + publishedAt: DateTime.now().subtract(const Duration(days: 5)), + ), + ], + ), OfferModel( id: '3', @@ -120,7 +182,7 @@ class MockOfferDataSource implements OfferDataSource { title: 'چیزبرگر', discount: '۲۰٪', imageUrls: [ - 'https://picsum.photos/seed/food/400/200', + 'https://picsum.photos/seed/food/ 400/200', 'https://picsum.photos/seed/burger1/400/400', 'https://picsum.photos/seed/burger2/400/400', ], @@ -166,6 +228,35 @@ class MockOfferDataSource implements OfferDataSource { longitude: 51.670, originalPrice: 150000, finalPrice: 120000, + features: [ + "تهیه شده از بهترین و تازه‌ترین مواد اولیه", + "محیطی دنج و مناسب برای قرارهای دوستانه", + "دارای منوی متنوع برای تمام سلیقه‌ها", + ], + discountInfo: const DiscountInfoModel( + name: "رفیق‌بازی", + description: "یه مهمونی دو نفره، یه تخفیف دوتایی؛ پس رفیق‌تو بیار و تخفیف‌تو ببر. دوستای بیشتر، تخفیف بیشتر.", + ), + comments: [ // <-- بخش نظرات اضافه شد + CommentModel( + id: 'c1', + userName: 'سارا رضایی', + rating: 4.5, + comment: 'کیفیت برگر عالی بود و محیط خیلی خوبی داشت. حتما دوباره سر می‌زنم!', + publishedAt: DateTime.now().subtract(const Duration(days: 2)), + uploadedImageUrls: [ + 'https://picsum.photos/seed/user_img1/200/200', + 'https://picsum.photos/seed/user_img2/200/200', + ] + ), + CommentModel( + id: 'c2', + userName: 'علی اکبری', + rating: 4, + comment: 'جای خوب و دنجی بود، فقط یکم شلوغ بود که طبیعیه. در کل راضی بودم.', + publishedAt: DateTime.now().subtract(const Duration(days: 5)), + ), + ], ), OfferModel( id: '4', @@ -219,6 +310,36 @@ class MockOfferDataSource implements OfferDataSource { longitude: 51.670, originalPrice: 150000, finalPrice: 120000, + features: [ + "تهیه شده از بهترین و تازه‌ترین مواد اولیه", + "محیطی دنج و مناسب برای قرارهای دوستانه", + "دارای منوی متنوع برای تمام سلیقه‌ها", + ], + discountInfo: const DiscountInfoModel( + name: "رفیق‌بازی", + description: "یه مهمونی دو نفره، یه تخفیف دوتایی؛ پس رفیق‌تو بیار و تخفیف‌تو ببر. دوستای بیشتر، تخفیف بیشتر.", + ), + comments: [ // <-- بخش نظرات اضافه شد + CommentModel( + id: 'c1', + userName: 'سارا رضایی', + rating: 4.5, + comment: 'کیفیت برگر عالی بود و محیط خیلی خوبی داشت. حتما دوباره سر می‌زنم!', + publishedAt: DateTime.now().subtract(const Duration(days: 2)), + uploadedImageUrls: [ + 'https://picsum.photos/seed/user_img1/200/200', + 'https://picsum.photos/seed/user_img2/200/200', + ] + ), + CommentModel( + id: 'c2', + userName: 'علی اکبری', + rating: 4, + comment: 'جای خوب و دنجی بود، فقط یکم شلوغ بود که طبیعیه. در کل راضی بودم.', + publishedAt: DateTime.now().subtract(const Duration(days: 5)), + ), + ], + ), ]; diff --git a/lib/data/models/discount_info_model.dart b/lib/data/models/discount_info_model.dart new file mode 100644 index 0000000..f11a10f --- /dev/null +++ b/lib/data/models/discount_info_model.dart @@ -0,0 +1,14 @@ +import 'package:equatable/equatable.dart'; + +class DiscountInfoModel extends Equatable { + final String name; + final String description; + + const DiscountInfoModel({ + required this.name, + required this.description, + }); + + @override + List get props => [name, description]; +} \ No newline at end of file diff --git a/lib/data/models/offer_model.dart b/lib/data/models/offer_model.dart index 46ebd78..703f5e8 100644 --- a/lib/data/models/offer_model.dart +++ b/lib/data/models/offer_model.dart @@ -1,4 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:proxibuy/data/models/comment_model.dart'; // <-- این خط اضافه شد +import 'package:proxibuy/data/models/discount_info_model.dart'; import 'package:proxibuy/data/models/working_hours.dart'; class OfferModel extends Equatable { @@ -20,6 +22,9 @@ class OfferModel extends Equatable { final double longitude; final double originalPrice; final double finalPrice; + final List features; + final DiscountInfoModel? discountInfo; + final List comments; // <-- این خط اضافه شد const OfferModel({ required this.id, @@ -40,6 +45,9 @@ class OfferModel extends Equatable { required this.longitude, required this.originalPrice, required this.finalPrice, + this.features = const [], + this.discountInfo, + this.comments = const [], // <-- این خط اضافه شد }); String get coverImageUrl => @@ -54,6 +62,9 @@ class OfferModel extends Equatable { ratingCount, latitude, longitude, + features, + discountInfo, + comments, // <-- این خط اضافه شد ]; String get distanceAsString { diff --git a/lib/presentation/pages/offers_page.dart b/lib/presentation/pages/offers_page.dart index e320eab..3934891 100644 --- a/lib/presentation/pages/offers_page.dart +++ b/lib/presentation/pages/offers_page.dart @@ -95,10 +95,9 @@ class _OffersPageState extends State { }, child: Row( children: [ - // چون asset مربوط به ویرایش وجود نداشت، از آیکون فلاتر استفاده شد - const Icon(Icons.edit, size: 18, color: AppColors.primary), + SvgPicture.asset(Assets.icons.edit.path), const SizedBox(width: 4), - const Text('ویرایش'), + const Text('ویرایش',style: TextStyle(color: AppColors.active),), ], ), ), diff --git a/lib/presentation/pages/product_detail_page.dart b/lib/presentation/pages/product_detail_page.dart index b448c8f..1bd1331 100644 --- a/lib/presentation/pages/product_detail_page.dart +++ b/lib/presentation/pages/product_detail_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; import 'package:maps_launcher/maps_launcher.dart'; @@ -9,6 +10,7 @@ import 'package:proxibuy/data/repositories/offer_repository.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'; +import 'package:proxibuy/presentation/widgets/comments_section.dart'; import 'package:slide_countdown/slide_countdown.dart'; class ProductDetailPage extends StatelessWidget { @@ -19,23 +21,76 @@ class ProductDetailPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => ProductDetailBloc( - offerRepository: context.read(), - )..add(ProductDetailFetchRequested(offerId: offerId)), + create: + (context) => ProductDetailBloc( + offerRepository: context.read(), + )..add(ProductDetailFetchRequested(offerId: offerId)), child: Scaffold( - body: BlocBuilder( - builder: (context, state) { - if (state is ProductDetailLoadInProgress || state is ProductDetailInitial) { - return const Center(child: CircularProgressIndicator()); - } - if (state is ProductDetailLoadFailure) { - return Center(child: Text('خطا: ${state.error}')); - } - if (state is ProductDetailLoadSuccess) { - return ProductDetailView(offer: state.offer); - } - return const SizedBox.shrink(); - }, + body: Stack( + children: [ + BlocBuilder( + builder: (context, state) { + if (state is ProductDetailLoadInProgress || + state is ProductDetailInitial) { + return const Center(child: CircularProgressIndicator()); + } + if (state is ProductDetailLoadFailure) { + return Center(child: Text('خطا: ${state.error}')); + } + if (state is ProductDetailLoadSuccess) { + return ProductDetailView(offer: state.offer) + .animate() + .fadeIn(duration: 400.ms, curve: Curves.easeOut) + .slideY( + begin: 0.2, + duration: 400.ms, + curve: Curves.easeOut, + ); + } + return const SizedBox.shrink(); + }, + ), + Positioned( + 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, + ), + ), + ], + ), + ) + .animate() + .fadeIn( + delay: 200.ms, + duration: 400.ms, + curve: Curves.easeOut, + ) + .slideY(begin: 2, duration: 500.ms, curve: Curves.easeOut), + ), + ], ), ), ); @@ -73,6 +128,7 @@ class _ProductDetailViewState extends State { textDirection: TextDirection.rtl, child: SingleChildScrollView( child: Stack( + clipBehavior: Clip.none, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -80,6 +136,7 @@ class _ProductDetailViewState extends State { _buildMainImage(context), const SizedBox(height: 52.5), _buildProductInfo(), + const SizedBox(height: 100), ], ), Positioned( @@ -122,12 +179,13 @@ class _ProductDetailViewState extends State { Positioned( top: 40, left: 16, - child: GestureDetector( - child: SvgPicture.asset(Assets.icons.back.path), - onTap: () { - Navigator.pop(context); - }, - ), + child: + GestureDetector( + child: SvgPicture.asset(Assets.icons.back.path), + onTap: () { + Navigator.pop(context); + }, + ).animate().fade(delay: 100.ms).scale(), ), ], ); @@ -147,22 +205,40 @@ class _ProductDetailViewState extends State { itemBuilder: (context, index) { final img = imageList[index]; if (img == _uploadKey) { - return _buildUploadButton(); + return _buildUploadButton().animate().fade().scale( + delay: (index * 50).ms, + ); } final isSelected = selectedImage == img; - return _buildThumbnail(img, isSelected); + return _buildThumbnail(img, isSelected, index); }, ), ), ); } - Widget _buildThumbnail(String img, bool isSelected) { + Widget _buildThumbnail(String img, bool isSelected, int index) { const grayscaleMatrix = [ - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0, 0, 0, 1, 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, ]; return GestureDetector( @@ -186,17 +262,37 @@ class _ProductDetailViewState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: ColorFiltered( - colorFilter: ColorFilter.matrix(isSelected ? [ - 1, 0, 0, 0, 0, - 0, 1, 0, 0, 0, - 0, 0, 1, 0, 0, - 0, 0, 0, 1, 0, - ] : grayscaleMatrix), + colorFilter: ColorFilter.matrix( + isSelected + ? [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ] + : grayscaleMatrix, + ), child: Image.network(img, width: 90, height: 90, fit: BoxFit.cover), ), ), ), - ); + ).animate().fade().scale(delay: (index * 50).ms); } Widget _buildUploadButton() { @@ -221,197 +317,490 @@ class _ProductDetailViewState extends State { } Widget _buildProductInfo() { - final remainingDuration = widget.offer.expiryTime.isAfter(DateTime.now()) - ? widget.offer.expiryTime.difference(DateTime.now()) - : Duration.zero; + final remainingDuration = + widget.offer.expiryTime.isAfter(DateTime.now()) + ? widget.offer.expiryTime.difference(DateTime.now()) + : Duration.zero; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0).copyWith(bottom: 24.0, top: 24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final animationList = [ + Row( children: [ - Row( - children: [ - SvgPicture.asset(Assets.icons.shop.path, height: 30), - const SizedBox(width: 6), - Text(widget.offer.storeName, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - ], - ), - const SizedBox(height: 16), - _buildInfoRow(icon: Assets.icons.location, text: widget.offer.address), - const SizedBox(height: 12), - ExpandableInfoRow( - icon: Assets.icons.clock, - titleWidget: Row( - children: [ - Text( - widget.offer.isOpen ? "باز است" : "بسته است", - style: TextStyle( - fontSize: 16, - color: widget.offer.isOpen ? Colors.green.shade700 : Colors.red.shade700, - fontWeight: FontWeight.normal, - ), - ), - ], + SvgPicture.asset(Assets.icons.shop.path, height: 30), + const SizedBox(width: 6), + Expanded( + child: Text( + widget.offer.storeName, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, ), - children: widget.offer.workingHours.map((wh) => Padding( - padding: const EdgeInsets.only(right: 10.0, bottom: 8.0, top: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(wh.day, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), - wh.isOpen - ? Text(wh.shifts.map((s) => '${s.openAt} - ${s.closeAt}').join(' | '), style: const TextStyle(fontSize: 15, color: AppColors.hint)) - : const Text('تعطیل', style: TextStyle(fontSize: 15, color: Colors.red)), - ], - ), - )).toList(), ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - InkWell( - onTap: () => _launchMaps(widget.offer.latitude, widget.offer.longitude, widget.offer.storeName), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Row( - children: [ - SvgPicture.asset( - Assets.icons.map.path, - width: 25, - height: 25, - colorFilter: const ColorFilter.mode(AppColors.button, BlendMode.srcIn), - ), - const SizedBox(width: 8), - const Text('مسیر فروشگاه روی نقشه', style: TextStyle(fontSize: 17, color: AppColors.button)), - ], - ), - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - width: 60, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: const BoxDecoration( - color: AppColors.selectedImg, - borderRadius: BorderRadius.only(topLeft: Radius.circular(8), topRight: Radius.circular(8)), - ), - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(widget.offer.rating.toString(), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 11)), - const SizedBox(width: 2), - SvgPicture.asset(Assets.icons.star.path, colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn)), - ], - ), - ), - ), - Container( - height: 30, - width: 60, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)), - ), - child: Center(child: Text('${widget.offer.ratingCount} نفر', style: const TextStyle(color: Colors.black, fontSize: 11, fontWeight: FontWeight.bold))), - ), - ], - ), - ], - ), - const SizedBox(height: 30), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), - decoration: BoxDecoration( - color: AppColors.singleOfferType, - borderRadius: BorderRadius.circular(20), - ), - child: Text("تخفیف ${widget.offer.discountType}", style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14)), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text('(${widget.offer.discount})', style: const TextStyle(fontSize: 16, color: AppColors.singleOfferType, fontWeight: FontWeight.normal)), - const SizedBox(width: 8), - Text( - widget.offer.originalPrice.toStringAsFixed(0), - style: TextStyle(fontSize: 16, color: Colors.grey.shade600, decoration: TextDecoration.lineThrough), - ), - ], - ), - const SizedBox(height: 1), - Text('${widget.offer.finalPrice.toStringAsFixed(0)} تومان', style: const TextStyle(color: AppColors.singleOfferType, fontSize: 22, fontWeight: FontWeight.bold)), - ], - ), - ], - ), - const SizedBox(height: 24), - _buildInfoRow(icon: Assets.icons.timerPause, text: "مهلت استفاده از تخفیف"), - const SizedBox(height: 10), - if (remainingDuration > Duration.zero) - Column( - children: [ - Localizations.override( - context: context, - locale: const Locale('en'), - child: SlideCountdown( - duration: remainingDuration, - slideDirection: SlideDirection.up, - separator: ':', - style: const TextStyle( - fontSize: 85, - fontWeight: FontWeight.bold, - color: AppColors.countdown, - ), - separatorStyle: const TextStyle( - fontSize: 40, - color: AppColors.countdown, - ), - decoration: const BoxDecoration( - color: Colors.white, - ), - shouldShowDays: (duration) => duration.inDays > 0, - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 60.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text("ثانیه", style: TextStyle(fontSize: 14, color: AppColors.selectedImg)), - Text("دقیقه", style: TextStyle(fontSize: 14, color: AppColors.selectedImg)), - Text("ساعت", style: TextStyle(fontSize: 14, color: AppColors.selectedImg)), - ], - ), - ), - ], - ), ], ), + const SizedBox(height: 16), + _buildInfoRow(icon: Assets.icons.location, text: widget.offer.address), + const SizedBox(height: 12), + ExpandableInfoRow( + icon: Assets.icons.clock, + titleWidget: Row( + children: [ + Text( + widget.offer.isOpen ? "باز است" : "بسته است", + style: TextStyle( + fontSize: 16, + color: + widget.offer.isOpen + ? Colors.green.shade700 + : Colors.red.shade700, + fontWeight: FontWeight.normal, + ), + ), + ], + ), + children: + widget.offer.workingHours + .map( + (wh) => Padding( + padding: const EdgeInsets.only( + right: 10.0, + bottom: 8.0, + top: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + wh.day, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + wh.isOpen + ? Text( + wh.shifts + .map((s) => '${s.openAt} - ${s.closeAt}') + .join(' | '), + style: const TextStyle( + fontSize: 15, + color: AppColors.hint, + ), + ) + : const Text( + 'تعطیل', + style: TextStyle(fontSize: 15, color: Colors.red), + ), + ], + ), + ), + ) + .toList(), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + InkWell( + onTap: + () => _launchMaps( + widget.offer.latitude, + widget.offer.longitude, + widget.offer.storeName, + ), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + SvgPicture.asset( + Assets.icons.map.path, + width: 25, + height: 25, + colorFilter: const ColorFilter.mode( + AppColors.button, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 8), + const Text( + 'مسیر فروشگاه روی نقشه', + style: TextStyle(fontSize: 17, color: AppColors.button), + ), + ], + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + width: 60, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: const BoxDecoration( + color: AppColors.selectedImg, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.offer.rating.toString(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + const SizedBox(width: 2), + SvgPicture.asset( + Assets.icons.star.path, + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + ), + ], + ), + ), + Container( + height: 30, + width: 60, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Center( + child: Text( + '${widget.offer.ratingCount} نفر', + style: const TextStyle( + color: Colors.black, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 30), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + decoration: BoxDecoration( + color: AppColors.singleOfferType, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + "تخفیف ${widget.offer.discountType}", + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 17, + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '(${widget.offer.discount})', + style: const TextStyle( + fontSize: 16, + color: AppColors.singleOfferType, + fontWeight: FontWeight.normal, + ), + ), + const SizedBox(width: 8), + Text( + widget.offer.originalPrice.toStringAsFixed(0), + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + decoration: TextDecoration.lineThrough, + ), + ), + ], + ), + const SizedBox(height: 1), + Text( + '${widget.offer.finalPrice.toStringAsFixed(0)} تومان', + style: const TextStyle( + color: AppColors.singleOfferType, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + _buildInfoRow( + icon: Assets.icons.timerPause, + text: "مهلت استفاده از تخفیف", + ), + const SizedBox(height: 10), + if (remainingDuration > Duration.zero) + Column( + children: [ + Localizations.override( + context: context, + locale: const Locale('en'), + child: SlideCountdown( + duration: remainingDuration, + slideDirection: SlideDirection.up, + separator: ':', + style: const TextStyle( + fontSize: 70, + fontWeight: FontWeight.bold, + color: AppColors.countdown, + ), + separatorStyle: const TextStyle( + fontSize: 40, + color: AppColors.countdown, + ), + decoration: const BoxDecoration(color: Colors.white), + shouldShowDays: (d) => d.inDays > 0, + shouldShowHours: (d) => d.inHours > 0, + shouldShowMinutes: + (d) => + d.inSeconds > + 0, // دقیقه تا آخرین ثانیه نمایش داده می‌شود + ), + ), + const SizedBox(height: 4), + _buildTimerLabels(remainingDuration), + ], + ), + const SizedBox(height: 24), + _buildFeaturesSection(), + const SizedBox(height: 24), + _buildDiscountTypeSection(), + const SizedBox(height: 24), + CommentsSection(comments: widget.offer.comments), + ].animate(interval: 80.ms).fade(duration: 300.ms).slideX(begin: -0.1); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + ).copyWith(bottom: 24.0, top: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: animationList + .animate(interval: 80.ms) + .fade(duration: 300.ms) + .slideX(begin: -0.1), + ), + ); + } + + Widget _buildTimerLabels(Duration duration) { + const double columnWidth = 80; + const labelStyle = TextStyle(fontSize: 14, color: AppColors.selectedImg); + + List labels = []; + + if (duration.inDays > 0) { + labels = [ + SizedBox( + width: columnWidth, + child: Center(child: Text("ثانیه", style: labelStyle)), + ), + SizedBox( + width: columnWidth, + child: Center(child: Text("دقیقه", style: labelStyle)), + ), + SizedBox( + width: columnWidth, + child: Center(child: Text("ساعت", style: labelStyle)), + ), + SizedBox( + width: columnWidth, + child: Center(child: Text("روز", style: labelStyle)), + ), + ]; + } else if (duration.inHours > 0) { + labels = [ + SizedBox( + width: columnWidth, + child: Center(child: Text("ثانیه", style: labelStyle)), + ), + SizedBox( + width: columnWidth, + child: Center(child: Text("دقیقه", style: labelStyle)), + ), + SizedBox( + width: columnWidth, + child: Center(child: Text("ساعت", style: labelStyle)), + ), + ]; + } else if (duration.inSeconds > 0) { + labels = [ + SizedBox( + width: columnWidth, + child: Center(child: Text("ثانیه", style: labelStyle)), + ), + SizedBox( + width: columnWidth, + child: Center(child: Text("دقیقه", style: labelStyle)), + ), + ]; + } + + return Row(mainAxisAlignment: MainAxisAlignment.center, children: labels); + } + + Widget _buildFeaturesSection() { + if (widget.offer.features.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'ویژگی‌ها', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Expanded(child: Divider(color: Colors.grey[400], thickness: 1)), + ], + ), + const SizedBox(height: 16), + ...widget.offer.features.map( + (feature) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.icons.tickSquare.path, + width: 22, + height: 22, + colorFilter: const ColorFilter.mode( + AppColors.confirm, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + feature, + style: TextStyle( + fontSize: 16, + height: 1.4, + color: AppColors.hint, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildDiscountTypeSection() { + final info = widget.offer.discountInfo; + if (info == null) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'نوع تخفیف', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + Expanded(child: Divider(color: Colors.grey[400], thickness: 1)), + ], + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.icons.tickSquare.path, + width: 22, + height: 22, + colorFilter: const ColorFilter.mode( + AppColors.confirm, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + "${widget.offer.discount} تخفیف ${info.name}", + style: const TextStyle( + fontSize: 16, + height: 1.4, + color: AppColors.confirm, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 7), + Padding( + padding: const EdgeInsets.only(right: 0.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: SvgPicture.asset(Assets.icons.warning2.path, height: 24), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + info.description, + style: const TextStyle(fontSize: 14, color: AppColors.hint), + ), + ), + ], + ), + ), + ], ); } Widget _buildInfoRow({required SvgGenImage icon, required String text}) { return Row( children: [ - SvgPicture.asset(icon.path, width: 22, height: 22, colorFilter: ColorFilter.mode(Colors.grey.shade600, BlendMode.srcIn)), + SvgPicture.asset( + icon.path, + width: 22, + height: 22, + colorFilter: ColorFilter.mode(Colors.grey.shade600, BlendMode.srcIn), + ), const SizedBox(width: 6), - Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: AppColors.hint))), + Expanded( + child: Text( + text, + style: const TextStyle(fontSize: 16, color: AppColors.hint), + ), + ), ], ); } @@ -452,14 +841,25 @@ class _ExpandableInfoRowState extends State { onTap: _toggleExpand, child: Row( children: [ - SvgPicture.asset(widget.icon.path, width: 22, height: 22, colorFilter: ColorFilter.mode(Colors.grey.shade600, BlendMode.srcIn)), + SvgPicture.asset( + widget.icon.path, + width: 22, + height: 22, + colorFilter: ColorFilter.mode( + Colors.grey.shade600, + BlendMode.srcIn, + ), + ), const SizedBox(width: 6), Expanded(child: widget.titleWidget), if (widget.children.isNotEmpty) AnimatedRotation( turns: _isExpanded ? 0.5 : 0, duration: const Duration(milliseconds: 200), - child: const Icon(Icons.keyboard_arrow_down, color: Colors.black), + child: const Icon( + Icons.keyboard_arrow_down, + color: Colors.black, + ), ), ], ), @@ -467,10 +867,13 @@ class _ExpandableInfoRowState extends State { AnimatedCrossFade( firstChild: Container(), secondChild: Column(children: widget.children), - crossFadeState: _isExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, + crossFadeState: + _isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, duration: const Duration(milliseconds: 300), ), ], ); } -} \ No newline at end of file +} diff --git a/lib/presentation/product_detail/bloc/product_detail_bloc.dart b/lib/presentation/product_detail/bloc/product_detail_bloc.dart index dda68e3..bdbc23e 100644 --- a/lib/presentation/product_detail/bloc/product_detail_bloc.dart +++ b/lib/presentation/product_detail/bloc/product_detail_bloc.dart @@ -1,6 +1,4 @@ import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:proxibuy/data/models/offer_model.dart'; import 'package:proxibuy/data/repositories/offer_repository.dart'; import 'package:proxibuy/presentation/product_detail/bloc/product_detail_event.dart'; import 'package:proxibuy/presentation/product_detail/bloc/product_detail_state.dart'; @@ -21,7 +19,6 @@ class ProductDetailBloc extends Bloc { ) async { emit(ProductDetailLoadInProgress()); try { - // از ریپازیتوری می‌خواهیم که محصول با این ID را به ما بدهد final offer = await _offerRepository.fetchOfferById(event.offerId); if (offer != null) { emit(ProductDetailLoadSuccess(offer)); diff --git a/lib/presentation/widgets/comments_section.dart b/lib/presentation/widgets/comments_section.dart new file mode 100644 index 0000000..9125fec --- /dev/null +++ b/lib/presentation/widgets/comments_section.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/data/models/comment_model.dart'; +import 'package:proxibuy/presentation/widgets/user_comment_card.dart'; + +class CommentsSection extends StatelessWidget { + final List comments; + + const CommentsSection({super.key, required this.comments}); + + @override + Widget build(BuildContext context) { + if (comments.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 10,), + Row( + children: [ + SvgPicture.asset(Assets.icons.line2.path), + const SizedBox(width: 8), + const Text('نظرات کاربران', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ], + ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: comments.length, + itemBuilder: (context, index) { + return UserCommentCard(comment: comments[index]); + }, + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/presentation/widgets/user_comment_card.dart b/lib/presentation/widgets/user_comment_card.dart new file mode 100644 index 0000000..ef49d08 --- /dev/null +++ b/lib/presentation/widgets/user_comment_card.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:proxibuy/core/config/app_colors.dart'; +import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/data/models/comment_model.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class CustomStarRating extends StatelessWidget { + final double rating; + final int starCount; + final double size; + + const CustomStarRating({ + super.key, + required this.rating, + this.starCount = 5, + this.size = 10.0, + }); + + @override + Widget build(BuildContext context) { + List stars = []; + double remaining = rating; + + for (int i = 0; i < starCount; i++) { + if (remaining >= 1) { + stars.add(_buildStar(Assets.icons.starFill.path)); + remaining -= 1; + } else if (remaining >= 0.5) { + stars.add(_buildStar(Assets.icons.star2.path)); + remaining = 0; + } else { + stars.add(_buildStar(Assets.icons.starHalf.path,)); + } + } + return Row(mainAxisSize: MainAxisSize.min, children: stars); + } + + Widget _buildStar(String asset, {Color? color}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: SvgPicture.asset( + asset, + width: size, + height: size, + colorFilter: color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, + ), + ); + } +} + +class UserCommentCard extends StatelessWidget { + final CommentModel comment; + + const UserCommentCard({super.key, required this.comment}); + + @override + Widget build(BuildContext context) { + final String formattedDate = intl.DateFormat('yyyy/MM/dd', 'fa').format(comment.publishedAt); + + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + // color: Colors.white, + margin: const EdgeInsets.symmetric(vertical: 16.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + comment.userName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const Spacer(), + Text( + formattedDate, + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + ], + ), + const SizedBox(height: 4), + CustomStarRating(rating: comment.rating, size: 18), + const SizedBox(height: 10), + Text( + '"${comment.comment}"', + style: const TextStyle(fontSize: 14, height: 1.6, color: Colors.black87), + ), + if (comment.uploadedImageUrls.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'عکس‌های آپلود شده توسط کاربر', + style: TextStyle( + fontWeight: FontWeight.normal, + color: AppColors.active, + fontSize: 14, + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: comment.uploadedImageUrls.length, + itemBuilder: (context, index) { + final imageUrl = comment.uploadedImageUrls[index]; + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.network( + imageUrl, + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + ); + }, + ), + ) + ], + ], + ), + ), + ); + } +} \ No newline at end of file