added comment

This commit is contained in:
mohamadmahdi jebeli 2025-08-04 12:15:17 +03:30
parent 608222e8a3
commit ce62c567c5
14 changed files with 694 additions and 137 deletions

View File

@ -8,4 +8,6 @@ class ApiConfig {
static const String addReservation = "/reservation/add"; static const String addReservation = "/reservation/add";
static const String getReservations = "/reservation/get"; static const String getReservations = "/reservation/get";
static const String updateFcmToken = "/user/firebaseUpdate"; static const String updateFcmToken = "/user/firebaseUpdate";
static const String addComment = "/comment/add";
static const String getComments = "/comment/get/";
} }

View File

@ -23,13 +23,18 @@ class CommentModel extends Equatable {
List<Object?> get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls]; List<Object?> get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls];
factory CommentModel.fromJson(Map<String, dynamic> json) { factory CommentModel.fromJson(Map<String, dynamic> json) {
final List<String> images = (json['UserImages'] as List<dynamic>?)
?.map((image) => image['Url'] as String)
.toList() ??
[];
return CommentModel( return CommentModel(
id: json['id'], id: json['ID'] ?? '',
userName: json['userName'], userName: json['User']?['Name'] ?? 'کاربر ناشناس',
rating: (json['rating'] as num).toDouble(), rating: (json['Score'] as num?)?.toDouble() ?? 0.0,
comment: json['comment'], comment: json['Text'] ?? '',
publishedAt: DateTime.parse(json['publishedAt']), publishedAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(),
uploadedImageUrls: List<String>.from(json['uploadedImageUrls'] ?? []), uploadedImageUrls: images,
); );
} }
} }

View File

@ -9,6 +9,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:proxibuy/core/config/http_overrides.dart'; import 'package:proxibuy/core/config/http_overrides.dart';
import 'package:proxibuy/firebase_options.dart'; import 'package:proxibuy/firebase_options.dart';
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.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/notification_preferences/bloc/notification_preferences_bloc.dart';
import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart'; import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart'; import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
@ -26,16 +27,6 @@ void main() async {
return token; 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(); WidgetsFlutterBinding.ensureInitialized();
await initializeService(); await initializeService();
@ -62,6 +53,9 @@ class MyApp extends StatelessWidget {
BlocProvider<NotificationPreferencesBloc>( BlocProvider<NotificationPreferencesBloc>(
create: (context) => NotificationPreferencesBloc(), create: (context) => NotificationPreferencesBloc(),
), ),
BlocProvider<CommentBloc>(
create: (context) => CommentBloc(),
),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Proxibuy', title: 'Proxibuy',

View File

@ -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<CommentEvent, CommentState> {
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<SubmitComment>(_onSubmitComment);
}
Future<void> _onSubmitComment(SubmitComment event, Emitter<CommentState> 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'));
}
}
}

View File

@ -0,0 +1,26 @@
import 'dart:io';
import 'package:equatable/equatable.dart';
abstract class CommentEvent extends Equatable {
const CommentEvent();
@override
List<Object> get props => [];
}
class SubmitComment extends CommentEvent {
final String discountId;
final String text;
final double score;
final List<File> images;
const SubmitComment({
required this.discountId,
required this.text,
required this.score,
required this.images,
});
@override
List<Object> get props => [discountId, text, score, images];
}

View File

@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
abstract class CommentState extends Equatable {
const CommentState();
@override
List<Object> 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<Object> get props => [error];
}

View File

@ -25,7 +25,6 @@ class AddPhotoScreen extends StatelessWidget {
required this.offer, required this.offer,
}); });
// متد ساخت توکن
Future<String> _generateQrToken(BuildContext context) async { Future<String> _generateQrToken(BuildContext context) async {
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID'); final userID = await storage.read(key: 'userID');

View File

@ -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<CommentPage> createState() => _CommentPageState();
}
class _CommentPageState extends State<CommentPage> {
final _commentController = TextEditingController();
double _rating = 0.0;
final List<File> _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: <Widget>[
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<CommentBloc, CommentState>(
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<CommentBloc>().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)),
),
],
);
},
);
}
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:dio/dio.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/api_config.dart';
import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/core/gen/assets.gen.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/data/models/offer_model.dart';
import 'package:proxibuy/presentation/pages/add_photo_screen.dart'; import 'package:proxibuy/presentation/pages/add_photo_screen.dart';
import 'package:proxibuy/presentation/pages/reservation_details_screen.dart'; import 'package:proxibuy/presentation/pages/reservation_details_screen.dart';
@ -87,20 +89,9 @@ class ProductDetailPage extends StatelessWidget {
headers: {'Authorization': 'Bearer $token'}, headers: {'Authorization': 'Bearer $token'},
); );
debugPrint("----------- REQUEST-----------");
debugPrint("URL: POST $url");
debugPrint("Headers: ${options.headers}");
debugPrint("Body: $data");
debugPrint("-----------------------------");
final response = final response =
await dio.post(url, data: data, options: options); 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 (context.mounted) Navigator.of(context).pop();
if (response.statusCode == 200) { if (response.statusCode == 200) {
@ -131,11 +122,6 @@ class ProductDetailPage extends StatelessWidget {
} on DioException catch (e) { } on DioException catch (e) {
if (context.mounted) Navigator.of(context).pop(); 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'] ?? final errorMessage = e.response?.data?['message'] ??
'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.'; 'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.';
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -145,10 +131,6 @@ class ProductDetailPage extends StatelessWidget {
); );
} catch (e) { } catch (e) {
if (context.mounted) Navigator.of(context).pop(); if (context.mounted) Navigator.of(context).pop();
debugPrint("---------- GENERAL ERROR -----------");
debugPrint(e.toString());
debugPrint("------------------------------------");
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(e.toString()), content: Text(e.toString()),
@ -205,14 +187,56 @@ class _ProductDetailViewState extends State<ProductDetailView> {
late List<String> imageList; late List<String> imageList;
late String selectedImage; late String selectedImage;
final String _uploadKey = 'upload_image'; final String _uploadKey = 'upload_image';
late Future<List<CommentModel>> _commentsFuture;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
imageList = List.from(widget.offer.imageUrls)..add(_uploadKey); 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<List<CommentModel>> _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<dynamic> 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) { void _launchMaps(double lat, double lon, String title) {
MapsLauncher.launchCoordinates(lat, lon, title); MapsLauncher.launchCoordinates(lat, lon, title);
} }
@ -405,8 +429,7 @@ class _ProductDetailViewState extends State<ProductDetailView> {
? widget.offer.expiryTime.difference(DateTime.now()) ? widget.offer.expiryTime.difference(DateTime.now())
: Duration.zero; : Duration.zero;
final formatCurrency = final formatCurrency = NumberFormat.decimalPattern('fa_IR');
NumberFormat.decimalPattern('fa_IR'); // Or 'en_US'
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0) padding: const EdgeInsets.symmetric(horizontal: 24.0)
@ -687,7 +710,26 @@ class _ProductDetailViewState extends State<ProductDetailView> {
const SizedBox(height: 24), const SizedBox(height: 24),
_buildDiscountTypeSection(), _buildDiscountTypeSection(),
const SizedBox(height: 24), const SizedBox(height: 24),
CommentsSection(comments: widget.offer.comments), FutureBuilder<List<CommentModel>>(
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( ].animate(interval: 80.ms).slideX(begin: -0.05).fadeIn(
duration: 400.ms, duration: 400.ms,
curve: Curves.easeOut, curve: Curves.easeOut,

View File

@ -1,12 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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:flutter_svg/svg.dart';
import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/core/gen/assets.gen.dart'; import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/data/models/offer_model.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:qr_flutter/qr_flutter.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
@ -30,17 +33,17 @@ class _ReservationConfirmationPageState
Timer? _timer; Timer? _timer;
Duration _remaining = Duration.zero; Duration _remaining = Duration.zero;
final AudioPlayer _audioPlayer = AudioPlayer(); final AudioPlayer _audioPlayer = AudioPlayer();
StreamSubscription? _mqttSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_playSound(); _playSound();
_calculateRemainingTime(); _calculateRemainingTime();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) { _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_calculateRemainingTime(); _calculateRemainingTime();
}); });
_listenToMqtt();
} }
void _playSound() async { void _playSound() async {
@ -69,14 +72,46 @@ class _ReservationConfirmationPageState
} }
} }
void _listenToMqtt() async {
final mqttService = context.read<MqttService>();
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 @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_audioPlayer.dispose(); _audioPlayer.dispose();
_mqttSubscription?.cancel();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Directionality( return Directionality(

View File

@ -1,11 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:flutter/material.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_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:proxibuy/core/gen/assets.gen.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:qr_flutter/qr_flutter.dart';
import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/data/models/offer_model.dart'; import 'package:proxibuy/data/models/offer_model.dart';
@ -25,6 +29,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
Timer? _timer; Timer? _timer;
Duration _remaining = Duration.zero; Duration _remaining = Duration.zero;
Future<String>? _qrTokenFuture; Future<String>? _qrTokenFuture;
StreamSubscription? _mqttSubscription; // برای مدیریت لیسنر MQTT
@override @override
void initState() { void initState() {
@ -57,11 +62,53 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
} }
void _toggleExpansion() { void _toggleExpansion() {
final isExpired = _remaining <= Duration.zero;
if (isExpired) return;
setState(() { setState(() {
_isExpanded = !_isExpanded; _isExpanded = !_isExpanded;
if (_isExpanded && _qrTokenFuture == null) { if (_isExpanded) {
if (_qrTokenFuture == null) {
_qrTokenFuture = _generateQrToken(); _qrTokenFuture = _generateQrToken();
} }
_listenToMqtt();
} else {
_mqttSubscription?.cancel();
}
});
}
void _listenToMqtt() async {
final mqttService = context.read<MqttService>();
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<ReservedListItemCard> {
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_mqttSubscription?.cancel();
super.dispose(); super.dispose();
} }
@ -104,13 +152,15 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: _buildOfferPrimaryDetails(), child: _buildOfferPrimaryDetails(),
), ),
_buildActionsRow(), _buildActionsRow(isExpired),
_buildExpansionPanel(), if (!isExpired) _buildExpansionPanel(),
], ],
); );
if (isExpired) { return Stack(
return ColorFiltered( children: [
if (isExpired)
ColorFiltered(
colorFilter: const ColorFilter.matrix(<double>[ colorFilter: const ColorFilter.matrix(<double>[
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.2126, 0.7152, 0.0722, 0, 0,
@ -118,12 +168,33 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
0, 0, 0, 1, 0, 0, 0, 0, 1, 0,
]), ]),
child: cardContent, 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,
),
),
),
),
),
],
); );
} }
return cardContent;
}
Widget _buildOfferPrimaryDetails() { Widget _buildOfferPrimaryDetails() {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(15, 25, 15, 25), padding: const EdgeInsets.fromLTRB(15, 25, 15, 25),
@ -195,13 +266,13 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
); );
} }
Widget _buildActionsRow() { Widget _buildActionsRow(bool isExpired) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
if (_remaining > Duration.zero) if (!isExpired)
Column( Column(
children: [ children: [
Localizations.override( Localizations.override(
@ -220,7 +291,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
fontSize: 20, fontSize: 20,
color: AppColors.countdown, color: AppColors.countdown,
), ),
decoration: const BoxDecoration(color: Colors.white), decoration: const BoxDecoration(color: Colors.transparent),
shouldShowDays: (d) => d.inDays > 0, shouldShowDays: (d) => d.inDays > 0,
shouldShowHours: (d) => d.inHours > 0, shouldShowHours: (d) => d.inHours > 0,
shouldShowMinutes: (d) => d.inSeconds > 0, shouldShowMinutes: (d) => d.inSeconds > 0,
@ -231,15 +302,9 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
], ],
) )
else else
const Text( const SizedBox(height: 0),
'منقضی شده',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
SizedBox(width: 10), SizedBox(width: 10),
if (!isExpired)
TextButton( TextButton(
onPressed: _toggleExpansion, onPressed: _toggleExpansion,
child: Row( child: Row(
@ -324,6 +389,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
Widget _buildExpansionPanel() { Widget _buildExpansionPanel() {
final formatCurrency = NumberFormat.decimalPattern('fa_IR'); final formatCurrency = NumberFormat.decimalPattern('fa_IR');
final isExpired = _remaining <= Duration.zero;
return AnimatedCrossFade( return AnimatedCrossFade(
firstChild: Container(), firstChild: Container(),
@ -395,6 +461,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
), ),
], ],
), ),
if (!isExpired) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Container( Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@ -429,6 +496,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
), ),
), ),
], ],
],
), ),
), ),
), ),

View File

@ -31,7 +31,7 @@ class CustomStarRating extends StatelessWidget {
stars.add(_buildStar(Assets.icons.star2.path)); stars.add(_buildStar(Assets.icons.star2.path));
remaining = 0; remaining = 0;
} else { } else {
stars.add(_buildStar(Assets.icons.starHalf.path,)); stars.add(_buildStar(Assets.icons.star2.path,));
} }
} }
return Row(mainAxisSize: MainAxisSize.min, children: stars); return Row(mainAxisSize: MainAxisSize.min, children: stars);

View File

@ -739,6 +739,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.28" 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: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -68,6 +68,7 @@ dependencies:
workmanager: ^0.7.0 workmanager: ^0.7.0
firebase_messaging: ^15.2.10 firebase_messaging: ^15.2.10
firebase_crashlytics: ^4.3.10 firebase_crashlytics: ^4.3.10
flutter_rating_bar: ^4.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: