From ce62c567c5bcc8a29ec8ab8cec6ba300a3c5ce91 Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Mon, 4 Aug 2025 12:15:17 +0330 Subject: [PATCH] added comment --- lib/core/config/api_config.dart | 4 +- lib/data/models/comment_model.dart | 17 +- lib/main.dart | 16 +- .../comment/bloc/comment_bloc.dart | 81 ++++++ .../comment/bloc/comment_event.dart | 26 ++ .../comment/bloc/comment_state.dart | 23 ++ lib/presentation/pages/add_photo_screen.dart | 1 - lib/presentation/pages/comment_page.dart | 273 ++++++++++++++++++ .../pages/product_detail_page.dart | 90 ++++-- .../pages/reservation_details_screen.dart | 61 +++- .../widgets/reserved_list_item_card.dart | 228 ++++++++++----- .../widgets/user_comment_card.dart | 2 +- pubspec.lock | 8 + pubspec.yaml | 1 + 14 files changed, 694 insertions(+), 137 deletions(-) create mode 100644 lib/presentation/comment/bloc/comment_bloc.dart create mode 100644 lib/presentation/comment/bloc/comment_event.dart create mode 100644 lib/presentation/comment/bloc/comment_state.dart create mode 100644 lib/presentation/pages/comment_page.dart diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart index 4f08183..e1bc137 100644 --- a/lib/core/config/api_config.dart +++ b/lib/core/config/api_config.dart @@ -8,4 +8,6 @@ class ApiConfig { static const String addReservation = "/reservation/add"; static const String getReservations = "/reservation/get"; static const String updateFcmToken = "/user/firebaseUpdate"; -} + static const String addComment = "/comment/add"; + static const String getComments = "/comment/get/"; +} \ No newline at end of file diff --git a/lib/data/models/comment_model.dart b/lib/data/models/comment_model.dart index dd15047..3ef2ee7 100644 --- a/lib/data/models/comment_model.dart +++ b/lib/data/models/comment_model.dart @@ -23,13 +23,18 @@ class CommentModel extends Equatable { List get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls]; factory CommentModel.fromJson(Map json) { + final List images = (json['UserImages'] as List?) + ?.map((image) => image['Url'] as String) + .toList() ?? + []; + return CommentModel( - id: json['id'], - userName: json['userName'], - rating: (json['rating'] as num).toDouble(), - comment: json['comment'], - publishedAt: DateTime.parse(json['publishedAt']), - uploadedImageUrls: List.from(json['uploadedImageUrls'] ?? []), + id: json['ID'] ?? '', + userName: json['User']?['Name'] ?? 'کاربر ناشناس', + rating: (json['Score'] as num?)?.toDouble() ?? 0.0, + comment: json['Text'] ?? '', + publishedAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + uploadedImageUrls: images, ); } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index e8976b8..160f894 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:proxibuy/core/config/http_overrides.dart'; import 'package:proxibuy/firebase_options.dart'; import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_bloc.dart'; // این ایمپورت اضافه شد import 'package:proxibuy/presentation/notification_preferences/bloc/notification_preferences_bloc.dart'; import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart'; import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart'; @@ -26,16 +27,6 @@ void main() async { return token; } - // FirebaseMessaging.instance.onTokenRefresh - // .listen((fcmToken) { - // // TODO: If necessary send token to application server. - // // Note: This callback is fired at each app startup and whenever a new - // // token is generated. - // }) - // .onError((err) { - // // Error getting token. - // }); - WidgetsFlutterBinding.ensureInitialized(); await initializeService(); @@ -62,6 +53,9 @@ class MyApp extends StatelessWidget { BlocProvider( create: (context) => NotificationPreferencesBloc(), ), + BlocProvider( + create: (context) => CommentBloc(), + ), ], child: MaterialApp( title: 'Proxibuy', @@ -140,4 +134,4 @@ class MyApp extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/presentation/comment/bloc/comment_bloc.dart b/lib/presentation/comment/bloc/comment_bloc.dart new file mode 100644 index 0000000..07fbfd3 --- /dev/null +++ b/lib/presentation/comment/bloc/comment_bloc.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:proxibuy/core/config/api_config.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_event.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_state.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:flutter/foundation.dart'; + +class CommentBloc extends Bloc { + late final Dio _dio; + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + CommentBloc() : super(CommentInitial()) { + _dio = Dio(); + _dio.interceptors.add( + LogInterceptor( + requestHeader: true, + requestBody: true, + responseBody: true, + responseHeader: false, + error: true, + logPrint: (obj) => debugPrint(obj.toString()), + ), + ); + on(_onSubmitComment); + } + + Future _onSubmitComment(SubmitComment event, Emitter emit) async { + if (event.text.isEmpty && event.score == 0) { + emit(const CommentSubmissionFailure("لطفا امتیاز یا نظری برای این تخفیف ثبت کنید.")); + return; + } + + emit(CommentSubmitting()); + try { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + emit(const CommentSubmissionFailure("شما وارد نشده‌اید.")); + return; + } + + final formData = FormData.fromMap({ + 'Discount': event.discountId, + 'Text': event.text, + 'Score': event.score, + }); + + for (File imageFile in event.images) { + formData.files.add(MapEntry( + 'Images', + await MultipartFile.fromFile( + imageFile.path, + filename: imageFile.path.split('/').last, + contentType: MediaType('image', 'jpeg'), + ), + )); + } + + final response = await _dio.post( + ApiConfig.baseUrl + ApiConfig.addComment, + data: formData, + options: Options( + headers: {'Authorization': 'Bearer $token'}, + ), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + emit(CommentSubmissionSuccess()); + } else { + emit(CommentSubmissionFailure(response.data['message'] ?? 'خطا در ارسال نظر')); + } + } on DioException catch (e) { + emit(CommentSubmissionFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور')); + } catch (e) { + emit(CommentSubmissionFailure('خطایی ناشناخته رخ داد: $e')); + } + } +} \ No newline at end of file diff --git a/lib/presentation/comment/bloc/comment_event.dart b/lib/presentation/comment/bloc/comment_event.dart new file mode 100644 index 0000000..1d73666 --- /dev/null +++ b/lib/presentation/comment/bloc/comment_event.dart @@ -0,0 +1,26 @@ +import 'dart:io'; +import 'package:equatable/equatable.dart'; + +abstract class CommentEvent extends Equatable { + const CommentEvent(); + + @override + List get props => []; +} + +class SubmitComment extends CommentEvent { + final String discountId; + final String text; + final double score; + final List images; + + const SubmitComment({ + required this.discountId, + required this.text, + required this.score, + required this.images, + }); + + @override + List get props => [discountId, text, score, images]; +} \ No newline at end of file diff --git a/lib/presentation/comment/bloc/comment_state.dart b/lib/presentation/comment/bloc/comment_state.dart new file mode 100644 index 0000000..84435b4 --- /dev/null +++ b/lib/presentation/comment/bloc/comment_state.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class CommentState extends Equatable { + const CommentState(); + + @override + List get props => []; +} + +class CommentInitial extends CommentState {} + +class CommentSubmitting extends CommentState {} + +class CommentSubmissionSuccess extends CommentState {} + +class CommentSubmissionFailure extends CommentState { + final String error; + + const CommentSubmissionFailure(this.error); + + @override + List get props => [error]; +} \ No newline at end of file diff --git a/lib/presentation/pages/add_photo_screen.dart b/lib/presentation/pages/add_photo_screen.dart index 5ea248b..77bb23f 100644 --- a/lib/presentation/pages/add_photo_screen.dart +++ b/lib/presentation/pages/add_photo_screen.dart @@ -25,7 +25,6 @@ class AddPhotoScreen extends StatelessWidget { required this.offer, }); - // متد ساخت توکن Future _generateQrToken(BuildContext context) async { const storage = FlutterSecureStorage(); final userID = await storage.read(key: 'userID'); diff --git a/lib/presentation/pages/comment_page.dart b/lib/presentation/pages/comment_page.dart new file mode 100644 index 0000000..5648030 --- /dev/null +++ b/lib/presentation/pages/comment_page.dart @@ -0,0 +1,273 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_rating_bar/flutter_rating_bar.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:proxibuy/core/config/app_colors.dart'; +import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_bloc.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_event.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_state.dart'; +import 'package:proxibuy/presentation/pages/offers_page.dart'; + +class CommentPage extends StatefulWidget { + final String discountId; + + const CommentPage({super.key, required this.discountId}); + + @override + State createState() => _CommentPageState(); +} + +class _CommentPageState extends State { + final _commentController = TextEditingController(); + double _rating = 0.0; + final List _images = []; + final ImagePicker _picker = ImagePicker(); + + @override + void dispose() { + _commentController.dispose(); + super.dispose(); + } + + void _pickImage(ImageSource source) async { + if (_images.length >= 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('شما فقط می‌توانید ۲ عکس اضافه کنید.')), + ); + return; + } + final pickedFile = await _picker.pickImage(source: source, imageQuality: 80); + if (pickedFile != null) { + setState(() { + _images.add(File(pickedFile.path)); + }); + } + } + + void _showImageSourceActionSheet() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)), + ), + builder: (context) { + return SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.photo_library, color: AppColors.primary), + title: const Text('انتخاب از گالری'), + onTap: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.gallery); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt, color: AppColors.primary), + title: const Text('گرفتن عکس با دوربین'), + onTap: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.camera); + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CommentBloc(), + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: Image.asset(Assets.images.userinfo.path, fit: BoxFit.cover), + ), + DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.65, + maxChildSize: 0.65, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 50, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 24), + const Text( + 'خریدت با موفقیت انجام شد. منتظر دیدار دوباره‌ات هستیم. لطفا نظرت رو در مورد این تخفیف بهمون بگو.', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Center( + child: RatingBar.builder( + initialRating: 0, + minRating: 1, + direction: Axis.horizontal, + itemCount: 5, + itemPadding: const EdgeInsets.symmetric(horizontal: 15.0), + itemBuilder: (context, _) => Icon( + Icons.star, + color: Colors.amber.shade700, + ), + onRatingUpdate: (rating) => setState(() => _rating = rating), + ), + ), + const SizedBox(height: 24), + TextField( + controller: _commentController, + maxLines: 4, + textAlign: TextAlign.right, + decoration: InputDecoration( + labelText: "گوشمون به شماست", + hintText: "نظراتت رو بگو...", + alignLabelWithHint: true, + suffixIcon: Padding( + padding: const EdgeInsets.all(12.0), + child: IconButton( + icon: SvgPicture.asset( + Assets.icons.galleryAdd.path, + color: _images.length >= 2 ? Colors.grey : AppColors.primary, + ), + onPressed: _images.length >= 2 ? null : _showImageSourceActionSheet, + ), + ), + ), + ), + const SizedBox(height: 16), + if (_images.isNotEmpty) + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _images.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Image.file( + _images[index], + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + ); + }, + ), + ), + const SizedBox(height: 24), + _buildActionButtons(), + ], + ), + ), + ); + }, + ), + ], + ), + ), + ); + } + + Widget _buildActionButtons() { + return BlocConsumer( + listener: (context, state) { + if (state is CommentSubmissionSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('نظر شما با موفقیت ثبت شد. ممنونیم!'), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const OffersPage()), + (route) => false, + ); + } else if (state is CommentSubmissionFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.error), backgroundColor: Colors.red), + ); + } + }, + builder: (context, state) { + final isLoading = state is CommentSubmitting; + return Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isLoading + ? null + : () { + context.read().add( + SubmitComment( + discountId: widget.discountId, + text: _commentController.text, + score: _rating, + images: _images, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.confirm, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), + ), + child: isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white), + ) + : const Text('ارسال'), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: isLoading + ? null + : () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const OffersPage()), + (route) => false, + ); + }, + child: const Text('رد شدن', style: TextStyle(color: Colors.black)), + ), + ], + ); + }, + ); + } +} \ 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 310f2f1..0f17ebe 100644 --- a/lib/presentation/pages/product_detail_page.dart +++ b/lib/presentation/pages/product_detail_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dio/dio.dart'; @@ -11,6 +12,7 @@ import 'package:maps_launcher/maps_launcher.dart'; import 'package:proxibuy/core/config/api_config.dart'; 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:proxibuy/data/models/offer_model.dart'; import 'package:proxibuy/presentation/pages/add_photo_screen.dart'; import 'package:proxibuy/presentation/pages/reservation_details_screen.dart'; @@ -87,20 +89,9 @@ class ProductDetailPage extends StatelessWidget { headers: {'Authorization': 'Bearer $token'}, ); - debugPrint("----------- REQUEST-----------"); - debugPrint("URL: POST $url"); - debugPrint("Headers: ${options.headers}"); - debugPrint("Body: $data"); - debugPrint("-----------------------------"); - final response = await dio.post(url, data: data, options: options); - debugPrint("---------- RESPONSE-----------"); - debugPrint("StatusCode: ${response.statusCode}"); - debugPrint("Data: ${response.data}"); - debugPrint("-----------------------------"); - if (context.mounted) Navigator.of(context).pop(); if (response.statusCode == 200) { @@ -131,11 +122,6 @@ class ProductDetailPage extends StatelessWidget { } on DioException catch (e) { if (context.mounted) Navigator.of(context).pop(); - debugPrint("---------- ERROR-----------"); - debugPrint("StatusCode: ${e.response?.statusCode}"); - debugPrint("Data: ${e.response?.data}"); - debugPrint("--------------------------"); - final errorMessage = e.response?.data?['message'] ?? 'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.'; ScaffoldMessenger.of(context).showSnackBar( @@ -145,10 +131,6 @@ class ProductDetailPage extends StatelessWidget { ); } catch (e) { if (context.mounted) Navigator.of(context).pop(); - debugPrint("---------- GENERAL ERROR -----------"); - debugPrint(e.toString()); - debugPrint("------------------------------------"); - ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(e.toString()), @@ -205,14 +187,56 @@ class _ProductDetailViewState extends State { late List imageList; late String selectedImage; final String _uploadKey = 'upload_image'; + late Future> _commentsFuture; @override void initState() { super.initState(); imageList = List.from(widget.offer.imageUrls)..add(_uploadKey); - selectedImage = imageList.first; + selectedImage = imageList.isNotEmpty ? imageList.first : 'https://via.placeholder.com/400x200.png?text=No+Image'; + _commentsFuture = _fetchComments(); } + Future> _fetchComments() async { + // 1. توکن را از حافظه امن بخوان + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'accessToken'); + + // 2. اگر توکن وجود نداشت، خطا برگردان + // هرچند کاربر لاگین نکرده معمولا به این صفحه دسترسی ندارد + if (token == null) { + throw Exception('Authentication token not found!'); + } + + try { + final dio = Dio(); + // 3. هدر Authorization را به درخواست اضافه کن + final response = await dio.get( + ApiConfig.baseUrl + ApiConfig.getComments + widget.offer.id, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200) { + final List commentsJson = response.data['data']['comments']; + return commentsJson.map((json) => CommentModel.fromJson(json)).toList(); + } else { + // خطاهای دیگر سرور + throw Exception('Failed to load comments with status code: ${response.statusCode}'); + } + } on DioException catch (e) { + // چاپ خطای کامل Dio برای دیباگ بهتر + debugPrint("DioException fetching comments: $e"); + if (e.response != null) { + debugPrint("Response data: ${e.response?.data}"); + } + throw Exception('Failed to load comments: ${e.message}'); + } catch (e) { + debugPrint("Error fetching comments: $e"); + throw Exception('An unknown error occurred: $e'); + } + } + // ############ END: FIX SECTION ############ + void _launchMaps(double lat, double lon, String title) { MapsLauncher.launchCoordinates(lat, lon, title); } @@ -405,8 +429,7 @@ class _ProductDetailViewState extends State { ? widget.offer.expiryTime.difference(DateTime.now()) : Duration.zero; - final formatCurrency = - NumberFormat.decimalPattern('fa_IR'); // Or 'en_US' + final formatCurrency = NumberFormat.decimalPattern('fa_IR'); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0) @@ -687,7 +710,26 @@ class _ProductDetailViewState extends State { const SizedBox(height: 24), _buildDiscountTypeSection(), const SizedBox(height: 24), - CommentsSection(comments: widget.offer.comments), + FutureBuilder>( + future: _commentsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('خطا در بارگذاری نظرات. لطفاً صفحه را رفرش کنید.')); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: Text('هنوز نظری برای این تخفیف ثبت نشده است.'), + ), + ); + } + return CommentsSection(comments: snapshot.data!); + }, + ), ].animate(interval: 80.ms).slideX(begin: -0.05).fadeIn( duration: 400.ms, curve: Curves.easeOut, diff --git a/lib/presentation/pages/reservation_details_screen.dart b/lib/presentation/pages/reservation_details_screen.dart index 0a2ed25..d80cdf7 100644 --- a/lib/presentation/pages/reservation_details_screen.dart +++ b/lib/presentation/pages/reservation_details_screen.dart @@ -1,12 +1,15 @@ import 'dart:async'; - import 'package:audioplayers/audioplayers.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.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:proxibuy/presentation/pages/comment_page.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:flutter_animate/flutter_animate.dart'; @@ -30,17 +33,17 @@ class _ReservationConfirmationPageState Timer? _timer; Duration _remaining = Duration.zero; final AudioPlayer _audioPlayer = AudioPlayer(); + StreamSubscription? _mqttSubscription; @override void initState() { super.initState(); - _playSound(); - _calculateRemainingTime(); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { _calculateRemainingTime(); }); + _listenToMqtt(); } void _playSound() async { @@ -69,14 +72,46 @@ class _ReservationConfirmationPageState } } + void _listenToMqtt() async { + final mqttService = context.read(); + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + final discountId = widget.offer.id; + + if (userID == null) { + debugPrint("MQTT Listener: UserID not found, cannot subscribe."); + return; + } + + final topic = 'user-order/$userID/$discountId'; + mqttService.subscribe(topic); + debugPrint("✅ Subscribed to MQTT topic: $topic"); + + _mqttSubscription = mqttService.messages.listen((message) { + debugPrint("✅ MQTT Message received on details page: $message"); + final receivedDiscountId = message['Discount']; + if (receivedDiscountId == discountId) { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => CommentPage(discountId: discountId), + ), + ); + }); + } + } + }); + } + @override void dispose() { _timer?.cancel(); _audioPlayer.dispose(); + _mqttSubscription?.cancel(); super.dispose(); } - @override Widget build(BuildContext context) { return Directionality( @@ -94,13 +129,13 @@ class _ReservationConfirmationPageState crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'تخفیف ${widget.offer.discountType} رزرو شد!', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ) + 'تخفیف ${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), @@ -227,7 +262,7 @@ class _ReservationConfirmationPageState SvgPicture.asset(Assets.icons.ticketDiscount.path), const SizedBox(width: 6), Text( - '(${(100-widget.offer.finalPrice/widget.offer.originalPrice*100).toInt()}%)', + '(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)', style: const TextStyle( fontSize: 16, color: AppColors.singleOfferType, @@ -401,4 +436,4 @@ class _ReservationConfirmationPageState ), ); } -} +} \ No newline at end of file diff --git a/lib/presentation/widgets/reserved_list_item_card.dart b/lib/presentation/widgets/reserved_list_item_card.dart index 30dd7f8..cf2be6f 100644 --- a/lib/presentation/widgets/reserved_list_item_card.dart +++ b/lib/presentation/widgets/reserved_list_item_card.dart @@ -1,11 +1,15 @@ import 'dart:async'; +import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart'; import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/presentation/pages/comment_page.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/data/models/offer_model.dart'; @@ -25,6 +29,7 @@ class _ReservedListItemCardState extends State { Timer? _timer; Duration _remaining = Duration.zero; Future? _qrTokenFuture; + StreamSubscription? _mqttSubscription; // برای مدیریت لیسنر MQTT @override void initState() { @@ -57,10 +62,52 @@ class _ReservedListItemCardState extends State { } void _toggleExpansion() { + final isExpired = _remaining <= Duration.zero; + if (isExpired) return; + setState(() { _isExpanded = !_isExpanded; - if (_isExpanded && _qrTokenFuture == null) { - _qrTokenFuture = _generateQrToken(); + if (_isExpanded) { + if (_qrTokenFuture == null) { + _qrTokenFuture = _generateQrToken(); + } + _listenToMqtt(); + } else { + _mqttSubscription?.cancel(); + } + }); + } + + void _listenToMqtt() async { + final mqttService = context.read(); + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + final discountId = widget.offer.id; + + if (userID == null) { + debugPrint("MQTT Listener: UserID not found, cannot subscribe."); + return; + } + + final topic = 'user-order/$userID/$discountId'; + mqttService.subscribe(topic); + debugPrint("✅ Subscribed to MQTT topic: $topic"); + + + _mqttSubscription = mqttService.messages.listen((message) { + debugPrint("✅ MQTT Message received on reserved card: $message"); + final receivedDiscountId = message['Discount']; + + if (receivedDiscountId == discountId) { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => CommentPage(discountId: discountId), + ), + ); + }); + } } }); } @@ -84,6 +131,7 @@ class _ReservedListItemCardState extends State { @override void dispose() { _timer?.cancel(); + _mqttSubscription?.cancel(); super.dispose(); } @@ -104,24 +152,47 @@ class _ReservedListItemCardState extends State { margin: EdgeInsets.zero, child: _buildOfferPrimaryDetails(), ), - _buildActionsRow(), - _buildExpansionPanel(), + _buildActionsRow(isExpired), + if (!isExpired) _buildExpansionPanel(), ], ); - if (isExpired) { - return ColorFiltered( - colorFilter: const ColorFilter.matrix([ - 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, - ]), - child: cardContent, - ); - } - - return cardContent; + return Stack( + children: [ + if (isExpired) + ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 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, + ]), + child: cardContent, + ) + else + cardContent, + if (isExpired) + Positioned( + top: 12, + left: -35, + child: Transform.rotate( + angle: -45 * (3.1415926535 / 180), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 4), + color: Colors.red, + child: const Text( + 'منقضی شده', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ), + ), + ], + ); } Widget _buildOfferPrimaryDetails() { @@ -195,13 +266,13 @@ class _ReservedListItemCardState extends State { ); } - Widget _buildActionsRow() { + Widget _buildActionsRow(bool isExpired) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (_remaining > Duration.zero) + if (!isExpired) Column( children: [ Localizations.override( @@ -220,7 +291,7 @@ class _ReservedListItemCardState extends State { fontSize: 20, color: AppColors.countdown, ), - decoration: const BoxDecoration(color: Colors.white), + decoration: const BoxDecoration(color: Colors.transparent), shouldShowDays: (d) => d.inDays > 0, shouldShowHours: (d) => d.inHours > 0, shouldShowMinutes: (d) => d.inSeconds > 0, @@ -231,36 +302,30 @@ class _ReservedListItemCardState extends State { ], ) else - const Text( - 'منقضی شده', - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - fontSize: 16, + const SizedBox(height: 0), + SizedBox(width: 10), + if (!isExpired) + TextButton( + onPressed: _toggleExpansion, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _isExpanded ? 'بستن' : 'اطلاعات بیشتر', + style: TextStyle(color: AppColors.active), + ), + const SizedBox(width: 12), + AnimatedRotation( + turns: _isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 300), + child: SvgPicture.asset( + Assets.icons.arrowDown.path, + height: 20, + ), + ), + ], ), ), - SizedBox(width: 10), - TextButton( - onPressed: _toggleExpansion, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _isExpanded ? 'بستن' : 'اطلاعات بیشتر', - style: TextStyle(color: AppColors.active), - ), - const SizedBox(width: 12), - AnimatedRotation( - turns: _isExpanded ? 0.5 : 0, - duration: const Duration(milliseconds: 300), - child: SvgPicture.asset( - Assets.icons.arrowDown.path, - height: 20, - ), - ), - ], - ), - ), ], ), ); @@ -324,6 +389,7 @@ class _ReservedListItemCardState extends State { Widget _buildExpansionPanel() { final formatCurrency = NumberFormat.decimalPattern('fa_IR'); + final isExpired = _remaining <= Duration.zero; return AnimatedCrossFade( firstChild: Container(), @@ -395,39 +461,41 @@ class _ReservedListItemCardState extends State { ), ], ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Color.fromARGB(255, 246, 246, 246), - borderRadius: BorderRadius.circular(16), + if (!isExpired) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Color.fromARGB(255, 246, 246, 246), + borderRadius: BorderRadius.circular(16), + ), + child: FutureBuilder( + future: _qrTokenFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 280.0, + child: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError) { + return const SizedBox( + height: 280.0, + child: Center(child: Text("خطا در ساخت کد QR")), + ); + } + if (snapshot.hasData) { + return QrImageView( + data: snapshot.data!, + version: QrVersions.auto, + size: 280.0, + ); + } + return const SizedBox.shrink(); + }, + ), ), - child: FutureBuilder( - future: _qrTokenFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const SizedBox( - height: 280.0, - child: Center(child: CircularProgressIndicator()), - ); - } - if (snapshot.hasError) { - return const SizedBox( - height: 280.0, - child: Center(child: Text("خطا در ساخت کد QR")), - ); - } - if (snapshot.hasData) { - return QrImageView( - data: snapshot.data!, - version: QrVersions.auto, - size: 280.0, - ); - } - return const SizedBox.shrink(); - }, - ), - ), + ], ], ), ), diff --git a/lib/presentation/widgets/user_comment_card.dart b/lib/presentation/widgets/user_comment_card.dart index f33a59b..d0a54ff 100644 --- a/lib/presentation/widgets/user_comment_card.dart +++ b/lib/presentation/widgets/user_comment_card.dart @@ -31,7 +31,7 @@ class CustomStarRating extends StatelessWidget { stars.add(_buildStar(Assets.icons.star2.path)); remaining = 0; } else { - stars.add(_buildStar(Assets.icons.starHalf.path,)); + stars.add(_buildStar(Assets.icons.star2.path,)); } } return Row(mainAxisSize: MainAxisSize.min, children: stars); diff --git a/pubspec.lock b/pubspec.lock index 9f7b7ff..85ae763 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -739,6 +739,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.28" + flutter_rating_bar: + dependency: "direct main" + description: + name: flutter_rating_bar + sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93 + url: "https://pub.dev" + source: hosted + version: "4.0.1" flutter_secure_storage: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0f8ac88..00ae85f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: workmanager: ^0.7.0 firebase_messaging: ^15.2.10 firebase_crashlytics: ^4.3.10 + flutter_rating_bar: ^4.0.1 dev_dependencies: flutter_test: