diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index eaf1789..4048b18 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,32 +11,29 @@ plugins { android { namespace = "com.example.proxibuy" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = "1.8" } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.example.proxibuy" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 23 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + multiDexEnabled = true } buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } @@ -45,3 +42,8 @@ android { flutter { source = "../.." } + +dependencies { + // این نسخه به آخرین نسخه مورد نیاز آپدیت شد + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index df814f7..28af001 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,18 +1,20 @@ + + + - - + + - - + - + + + - + - + \ No newline at end of file diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart index 8a56d7d..e1bc137 100644 --- a/lib/core/config/api_config.dart +++ b/lib/core/config/api_config.dart @@ -5,6 +5,9 @@ class ApiConfig { static const String updateUser = "/user/updateName"; static const String updateCategories = "/user/favoriteCategory"; static const String getFavoriteCategories = "/user/getfavoriteCategory"; - static const String addReservation = "/reservation/add"; - static const String getReservations = "/reservation/get"; + 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/core/config/app_colors.dart b/lib/core/config/app_colors.dart index 5fbbd02..6eaa2f8 100644 --- a/lib/core/config/app_colors.dart +++ b/lib/core/config/app_colors.dart @@ -17,4 +17,6 @@ class AppColors { static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251); static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28); static const Color uploadElevated = Color.fromARGB(255, 233, 245, 254); + static const Color backgroundConfirm = Color.fromARGB(255, 237, 247, 238); + static const Color notifIcon = Color.fromARGB(255, 179, 38, 30); } \ 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/data/models/notification_model.dart b/lib/data/models/notification_model.dart new file mode 100644 index 0000000..107eca9 --- /dev/null +++ b/lib/data/models/notification_model.dart @@ -0,0 +1,37 @@ +import 'package:proxibuy/data/models/offer_model.dart'; + +class NotificationModel { + final String id; + final String description; + final DateTime createdAt; + final String discountId; + final String discountName; + final String shopName; + final bool status; + OfferModel? offer; + + NotificationModel({ + required this.id, + required this.description, + required this.createdAt, + required this.discountId, + required this.discountName, + required this.shopName, + required this.status, + this.offer, + }); + + factory NotificationModel.fromJson(Map json) { + final bool statusValue = json['Status'] is bool ? json['Status'] : false; + + return NotificationModel( + id: json['ID'] ?? '', + description: json['Description'] ?? 'No description available.', + createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + discountId: json['Discount']?['ID'] ?? '', + discountName: json['Discount']?['Name'] ?? 'Unknown Discount', + shopName: json['Discount']?['Shop']?['Name'] ?? 'Unknown Shop', + status: statusValue, + ); + } +} \ No newline at end of file diff --git a/lib/data/models/offer_model.dart b/lib/data/models/offer_model.dart index 68db1ac..59ff293 100644 --- a/lib/data/models/offer_model.dart +++ b/lib/data/models/offer_model.dart @@ -149,7 +149,7 @@ class OfferModel extends Equatable { rating: 0.0, ratingCount: 0, comments: [], - discountInfo: json['Description'], + discountInfo: json['Description'] ?? 'توضیحات موجود نیست', ); } @@ -157,7 +157,29 @@ class OfferModel extends Equatable { imageUrls.isNotEmpty ? imageUrls.first : 'https://via.placeholder.com/400x200.png?text=No+Image'; @override - List get props => [id]; + List get props => [ + id, + storeName, + title, + discount, + imageUrls, + category, + distanceInMeters, + expiryTime, + address, + workingHours, + discountType, + isOpen, + rating, + ratingCount, + latitude, + longitude, + originalPrice, + finalPrice, + features, + discountInfo, + comments, + ]; String get distanceAsString { if (distanceInMeters < 1000) { diff --git a/lib/main.dart b/lib/main.dart index f1916ed..160f894 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,6 @@ -// lib/main.dart - import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -10,18 +9,29 @@ 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'; +import 'package:proxibuy/services/background_service.dart'; import 'package:proxibuy/services/mqtt_service.dart'; import 'core/config/app_colors.dart'; import 'package:proxibuy/presentation/pages/splash_screen.dart'; - void main() async { + + Future getFcmToken() async { + FirebaseMessaging messaging = FirebaseMessaging.instance; + String? token = await messaging.getToken(); + print("🔥 Firebase Messaging Token: $token"); + return token; + } + WidgetsFlutterBinding.ensureInitialized(); + + await initializeService(); + HttpOverrides.global = MyHttpOverrides(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); Animate.restartOnHotReload = true; runApp(const MyApp()); @@ -34,22 +44,18 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - RepositoryProvider( - create: (context) => MqttService(), - ), + RepositoryProvider(create: (context) => MqttService()), BlocProvider( create: (context) => AuthBloc()..add(CheckAuthStatusEvent()), ), - BlocProvider( - create: (context) => ReservationCubit(), - ), - BlocProvider( - create: (context) => OffersBloc(), - ), + BlocProvider(create: (context) => ReservationCubit()), + BlocProvider(create: (context) => OffersBloc()), BlocProvider( create: (context) => NotificationPreferencesBloc(), ), - + BlocProvider( + create: (context) => CommentBloc(), + ), ], child: MaterialApp( title: 'Proxibuy', diff --git a/lib/presentation/auth/bloc/auth_bloc.dart b/lib/presentation/auth/bloc/auth_bloc.dart index fb9ab81..99a9c78 100644 --- a/lib/presentation/auth/bloc/auth_bloc.dart +++ b/lib/presentation/auth/bloc/auth_bloc.dart @@ -1,5 +1,3 @@ -// lib/presentation/auth/bloc/auth_bloc.dart - import 'package:bloc/bloc.dart'; import 'package:dio/dio.dart'; import 'package:equatable/equatable.dart'; @@ -15,7 +13,7 @@ class AuthBloc extends Bloc { late final Dio _dio; final _storage = const FlutterSecureStorage(); - AuthBloc() : super(AuthInitial()) { + AuthBloc() : super(AuthUnknown()) { _dio = Dio(); _dio.interceptors.add( LogInterceptor( @@ -31,10 +29,13 @@ class AuthBloc extends Bloc { on(_onVerifyOTP); on(_onUpdateUserInfo); on(_onLogout); + on(_onSendFcmToken); } Future _onCheckAuthStatus( - CheckAuthStatusEvent event, Emitter emit) async { + CheckAuthStatusEvent event, + Emitter emit, + ) async { final token = await _storage.read(key: 'accessToken'); if (token != null && token.isNotEmpty) { emit(AuthSuccess()); @@ -43,6 +44,7 @@ class AuthBloc extends Bloc { } } + Future _onSendOTP(SendOTPEvent event, Emitter emit) async { emit(AuthLoading()); try { @@ -52,10 +54,12 @@ class AuthBloc extends Bloc { ); if (isClosed) return; if (response.statusCode == 200) { - emit(AuthCodeSentSuccess( - phone: event.phoneNumber, - countryCode: event.countryCode, - )); + emit( + AuthCodeSentSuccess( + phone: event.phoneNumber, + countryCode: event.countryCode, + ), + ); } else { emit(AuthFailure(response.data['message'] ?? 'خطایی رخ داد')); } @@ -65,7 +69,10 @@ class AuthBloc extends Bloc { } } - Future _onVerifyOTP(VerifyOTPEvent event, Emitter emit) async { + Future _onVerifyOTP( + VerifyOTPEvent event, + Emitter emit, + ) async { emit(AuthLoading()); try { final response = await _dio.post( @@ -97,8 +104,12 @@ class AuthBloc extends Bloc { } Future _onUpdateUserInfo( - UpdateUserInfoEvent event, Emitter emit) async { - debugPrint("AuthBloc: 🔵 ایونت UpdateUserInfoEvent دریافت شد با نام: ${event.name}"); + UpdateUserInfoEvent event, + Emitter emit, + ) async { + debugPrint( + "AuthBloc: 🔵 ایونت UpdateUserInfoEvent دریافت شد با نام: ${event.name}", + ); emit(AuthLoading()); try { final token = await _storage.read(key: 'accessToken'); @@ -115,30 +126,59 @@ class AuthBloc extends Bloc { options: Options(headers: {'Authorization': 'Bearer $token'}), ); - debugPrint("AuthBloc: 🟠 پاسخ سرور دریافت شد. StatusCode: ${response.statusCode}"); + debugPrint( + "AuthBloc: 🟠 پاسخ سرور دریافت شد. StatusCode: ${response.statusCode}", + ); if (isClosed) { - debugPrint("AuthBloc: 🔴 خطا: BLoC قبل از اتمام عملیات بسته شده است."); - return; + debugPrint("AuthBloc: 🔴 خطا: BLoC قبل از اتمام عملیات بسته شده است."); + return; } if (response.statusCode == 200) { - debugPrint("AuthBloc: ✅ درخواست موفق بود. در حال emit کردن AuthSuccess..."); + debugPrint( + "AuthBloc: ✅ درخواست موفق بود. در حال emit کردن AuthSuccess...", + ); emit(AuthSuccess()); } else { - debugPrint("AuthBloc: 🔴 سرور پاسخ ناموفق داد: ${response.data['message']}"); + debugPrint( + "AuthBloc: 🔴 سرور پاسخ ناموفق داد: ${response.data['message']}", + ); emit(AuthFailure(response.data['message'] ?? 'خطا در ثبت اطلاعات')); } } on DioException catch (e) { - debugPrint("AuthBloc: 🔴 خطای DioException رخ داد: ${e.response?.data['message']}"); + debugPrint( + "AuthBloc: 🔴 خطای DioException رخ داد: ${e.response?.data['message']}", + ); if (isClosed) return; emit(AuthFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور')); } } - Future _onLogout(LogoutEvent event, Emitter emit) async { await _storage.deleteAll(); emit(AuthInitial()); } -} \ No newline at end of file + + Future _onSendFcmToken( + SendFcmTokenEvent event, + Emitter emit, + ) async { + try { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + emit(const AuthFailure("شما وارد نشده‌اید.")); + return; + } + + await _dio.post( + ApiConfig.baseUrl + ApiConfig.updateFcmToken, + data: {'Token': event.fcmToken}, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + print("Firebase token: ${event.fcmToken}"); + } on DioException catch (e) { + debugPrint("Error sending FCM token: ${e.response?.data['message']}"); + } + } +} diff --git a/lib/presentation/auth/bloc/auth_event.dart b/lib/presentation/auth/bloc/auth_event.dart index e7ca537..3dcf6ef 100644 --- a/lib/presentation/auth/bloc/auth_event.dart +++ b/lib/presentation/auth/bloc/auth_event.dart @@ -25,4 +25,9 @@ class UpdateUserInfoEvent extends AuthEvent { final String gender; UpdateUserInfoEvent({required this.name, required this.gender}); +} + +class SendFcmTokenEvent extends AuthEvent { + final String fcmToken; + SendFcmTokenEvent({required this.fcmToken}); } \ No newline at end of file diff --git a/lib/presentation/auth/bloc/auth_state.dart b/lib/presentation/auth/bloc/auth_state.dart index 5eff7bc..90271b2 100644 --- a/lib/presentation/auth/bloc/auth_state.dart +++ b/lib/presentation/auth/bloc/auth_state.dart @@ -7,6 +7,8 @@ abstract class AuthState extends Equatable { List get props => []; } +class AuthUnknown extends AuthState {} + class AuthInitial extends AuthState {} class AuthLoading extends AuthState {} 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/notification_preferences/bloc/notification_preferences_bloc.dart b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart index deff79c..679ff79 100644 --- a/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart +++ b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart @@ -26,12 +26,12 @@ class NotificationPreferencesBloc LoadCategories event, Emitter emit) { final categories = [ CategoryEntity(id: "e33dd7f9-5b20-4273-8eea-59da6ca5f206", name: 'لوازم دیجیتال', icon: Assets.icons.digital), - CategoryEntity(id: "b73a868a-a2d2-4d96-8fd4-615327ed9629", name: 'کافی‌شاپ', icon: Assets.icons.coffeeshop), + CategoryEntity(id: "b73a868a-a2d2-4d96-8fd4-615327ed9629", name: 'کافی شاپ', icon: Assets.icons.coffeeshop), // Change Here CategoryEntity(id: "b5881239-bfd5-4c27-967a-187316a7e0b7", name: 'رستوران', icon: Assets.icons.resturan), - CategoryEntity(id: "6803b940-3e19-48cd-9190-28d9f25421ff", name: 'فست‌فود', icon: Assets.icons.fastfood), + CategoryEntity(id: "6803b940-3e19-48cd-9190-28d9f25421ff", name: 'فست فود', icon: Assets.icons.fastfood), // Change Here CategoryEntity(id: "71e371f8-a47a-4a58-aee6-4ed0f26bf29b", name: 'پوشاک', icon: Assets.icons.pooshak), CategoryEntity(id: "42acff41-1165-4e62-89b9-58db7329ec3a", name: 'تریا', icon: Assets.icons.teria), - CategoryEntity(id: "2f38918c-5566-4aec-a0a9-2c7c48b1e878", name: 'کیف‌وکفش', icon: Assets.icons.kafsh), + CategoryEntity(id: "2f38918c-5566-4aec-a0a9-2c7c48b1e878", name: 'کیف و کفش', icon: Assets.icons.kafsh), // Change Here CategoryEntity(id: "52c51010-3a63-4264-a350-e011c889f3dd", name: 'سینما', icon: Assets.icons.cinama), CategoryEntity(id: "34185954-f79f-4b9e-8eb2-1702679c40a0", name: 'لوازم آرایشی', icon: Assets.icons.arayesh), CategoryEntity(id: "e4517b0c-aacf-4758-94bd-85f45062980f", name: 'طلا و زیورآلات', icon: Assets.icons.tala), @@ -126,6 +126,4 @@ class NotificationPreferencesBloc ResetSubmissionStatus event, Emitter emit) { emit(state.copyWith(submissionSuccess: false)); } -} - - +} \ 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..53a0c24 --- /dev/null +++ b/lib/presentation/pages/comment_page.dart @@ -0,0 +1,414 @@ +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'; +import 'package:flutter_animate/flutter_animate.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 _removeImage(int index) { + setState(() { + _images.removeAt(index); + }); + } + + 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( + backgroundColor: Colors.grey[50], + appBar: _buildCustomAppBar(), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 24), + Center(child: _buildRatingCard()), + const SizedBox(height: 16), + _buildCommentCard(), + const SizedBox(height: 16), + _buildImagePickerSection(), + const SizedBox(height: 32), + _buildActionButtons(), + ] + .animate(interval: 100.ms) + .fadeIn(duration: 400.ms) + .slideY(begin: 0.2, curve: Curves.easeOut), + ), + ), + ), + ); + } + + PreferredSizeWidget _buildCustomAppBar() { + return PreferredSize( + preferredSize: const Size.fromHeight(70.0), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(15), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text( + 'ثبت نظر', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.normal, + fontSize: 18, + ), + ), + IconButton( + icon: SvgPicture.asset(Assets.icons.arrowLeft.path), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Center( + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.confirm.withOpacity(0.1), + ), + child: Icon( + Icons.check_circle_outline, + color: AppColors.confirm, + size: 50, + ), + ), + const SizedBox(height: 16), + const Text( + 'خریدت با موفقیت انجام شد. منتظر دیدار دوباره‌ات هستیم. لطفا نظرت رو در مورد این تخفیف بهمون بگو.', + style: TextStyle(fontSize: 16, color: Colors.black, height: 1.5), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildRatingCard() { + return Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const Text( + "امتیاز شما چقدره؟", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + RatingBar.builder( + initialRating: 0, + minRating: 1, + direction: Axis.horizontal, + itemCount: 5, + itemPadding: const EdgeInsets.symmetric(horizontal: 7.0), + itemBuilder: + (context, _) => + Icon(Icons.star, color: Colors.amber.shade700), + onRatingUpdate: (rating) => setState(() => _rating = rating), + ), + ], + ), + ), + ); + } + + Widget _buildCommentCard() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _commentController, + maxLines: 4, + textAlign: TextAlign.right, + decoration: InputDecoration( + labelText: "گوشمون به شماست", + hintText: "نظراتت رو بگو...", + alignLabelWithHint: true, + suffixIcon: Padding(padding: const EdgeInsets.all(12.0)), + ), + ), + ); + } + + Widget _buildImagePickerSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "افزودن عکس (اختیاری)", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + "می‌تونی عکس تجربه خریدت رو باهامون به اشتراک بذاری!", + style: TextStyle(fontSize: 14, color: AppColors.hint), + ), + const SizedBox(height: 16), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ...List.generate(_images.length, (index) { + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Image.file( + _images[index], + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + Positioned( + top: -8, + right: -8, + child: GestureDetector( + onTap: () => _removeImage(index), + child: Container( + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 16, + ), + ), + ), + ), + ], + ), + ); + }), + if (_images.length < 2) + GestureDetector( + onTap: _showImageSourceActionSheet, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.grey.shade400, + width: 1.5, + style: BorderStyle.solid, + ), + ), + child: const Icon( + Icons.add_a_photo_outlined, + color: Colors.grey, + size: 30, + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildActionButtons() { + return BlocConsumer( + listener: (context, state) { + if (state is CommentSubmissionSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('نظر شما با موفقیت ثبت شد. ممنونیم!'), + backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, + ), + ); + 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, + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + 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), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/presentation/pages/notification_preferences_page.dart b/lib/presentation/pages/notification_preferences_page.dart index 210712c..cce381d 100644 --- a/lib/presentation/pages/notification_preferences_page.dart +++ b/lib/presentation/pages/notification_preferences_page.dart @@ -130,7 +130,7 @@ class _NotificationPreferencesPageState const SizedBox(width: 8), ], ), - body: BlocListener( listener: (context, state) async { if (state.submissionSuccess) { @@ -157,7 +157,7 @@ class _NotificationPreferencesPageState ); } } else { - if (mounted) Navigator.of(context).pop(); + if (mounted) Navigator.of(context).pop(); } } catch (e) { if (mounted) Navigator.of(context).pop(); @@ -176,151 +176,151 @@ class _NotificationPreferencesPageState ); } }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + builder: (context, state) { + return Stack( children: [ - const Text( - 'دریافت اعلان', - style: TextStyle( - fontFamily: 'Dana', - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - const Divider(), - RichText( - text: TextSpan( - style: TextStyle( - fontFamily: 'Dana', - fontSize: 14, - color: AppColors.hint, - height: 1.5, - ), - children: const [ - TextSpan( - text: - 'ترجیح می‌دی از کدام دسته‌بندی‌ها اعلان تخفیف دریافت کنی؟ ', - style: - TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'دریافت اعلان', + style: TextStyle( + fontFamily: 'Dana', + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), - TextSpan(text: '(حداقل یک مورد رو انتخاب کن).'), + const SizedBox(height: 4), + const Divider(), + RichText( + text: TextSpan( + style: TextStyle( + fontFamily: 'Dana', + fontSize: 14, + color: AppColors.hint, + height: 1.5, + ), + children: const [ + TextSpan( + text: + 'ترجیح می‌دی از کدام دسته‌بندی‌ها اعلان تخفیف دریافت کنی؟ ', + style: + TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + TextSpan(text: '(حداقل یک مورد رو انتخاب کن).'), + ], + ), + ), + const SizedBox(height: 24), + Expanded( + child: Builder( + builder: (context) { + if (state.categories.isEmpty && state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + final double horizontalPadding = 24.0; + final double crossAxisSpacing = 16.0; + final int crossAxisCount = 3; + final screenWidth = MediaQuery.of(context).size.width; + final itemWidth = (screenWidth - + (horizontalPadding * 2) - + (crossAxisSpacing * (crossAxisCount - 1))) / + crossAxisCount; + final itemHeight = itemWidth / 0.9; + + return SingleChildScrollView( + child: Wrap( + spacing: crossAxisSpacing, + runSpacing: 24.0, + alignment: WrapAlignment.center, + children: state.categories.map((category) { + final isSelected = + state.selectedCategoryIds.contains(category.id); + return SizedBox( + width: itemWidth, + height: itemHeight, + child: CategorySelectionCard( + name: category.name, + icon: category.icon, + isSelected: isSelected, + showSelectableIndicator: + state.selectedCategoryIds.isNotEmpty, + onTap: () { + context.read().add( + ToggleCategorySelection(category.id)); + }, + ), + ); + }).toList(), + ), + ); + }, + ), + ), + if (state.selectedCategoryIds.isNotEmpty) + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: state.isLoading + ? null + : () async { + final bloc = + context.read(); + + final selectedCategoryNames = bloc + .state.categories + .where((cat) => bloc.state + .selectedCategoryIds + .contains(cat.id)) + .map((cat) => cat.name) + .toList(); + + final prefs = + await SharedPreferences.getInstance(); + await prefs.setStringList( + 'user_selected_categories', + selectedCategoryNames); + + bloc.add(SubmitPreferences()); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.confirm, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.withOpacity(0.5), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + ), + child: const Text( + 'اعمال', + style: TextStyle( + fontFamily: 'Dana', + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 20), ], ), ), - const SizedBox(height: 24), - Expanded( - child: BlocBuilder( - builder: (context, state) { - if (state.categories.isEmpty && state.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - final double horizontalPadding = 24.0; - final double crossAxisSpacing = 16.0; - final int crossAxisCount = 3; - final screenWidth = MediaQuery.of(context).size.width; - final itemWidth = (screenWidth - - (horizontalPadding * 2) - - (crossAxisSpacing * (crossAxisCount - 1))) / - crossAxisCount; - final itemHeight = itemWidth / 0.9; - - return SingleChildScrollView( - child: Wrap( - spacing: crossAxisSpacing, - runSpacing: 24.0, - alignment: WrapAlignment.center, - children: state.categories.map((category) { - final isSelected = - state.selectedCategoryIds.contains(category.id); - return SizedBox( - width: itemWidth, - height: itemHeight, - child: CategorySelectionCard( - name: category.name, - icon: category.icon, - isSelected: isSelected, - showSelectableIndicator: - state.selectedCategoryIds.isNotEmpty, - onTap: () { - context.read().add( - ToggleCategorySelection(category.id)); - }, - ), - ); - }).toList(), - ), - ); - }, + if (state.isLoading) + Container( + color: Colors.black.withOpacity(0.4), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white, + ), + ), ), - ), - BlocBuilder( - builder: (context, state) { - final areCategoriesSelected = - state.selectedCategoryIds.isNotEmpty; - - if (areCategoriesSelected) { - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: !state.isLoading - ? () async { - final bloc = - context.read(); - - final selectedCategoryNames = bloc - .state.categories - .where((cat) => bloc.state - .selectedCategoryIds - .contains(cat.id)) - .map((cat) => cat.name) - .toList(); - - final prefs = - await SharedPreferences.getInstance(); - await prefs.setStringList( - 'user_selected_categories', - selectedCategoryNames); - - bloc.add(SubmitPreferences()); - } - : null, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.confirm, - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(50), - ), - ), - child: state.isLoading - ? const CircularProgressIndicator( - color: Colors.white) - : const Text( - 'اعمال', - style: TextStyle( - fontFamily: 'Dana', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - const SizedBox(height: 20), ], - ), - ), + ); + }, ), ); } diff --git a/lib/presentation/pages/offers_page.dart b/lib/presentation/pages/offers_page.dart index bf97334..233926c 100644 --- a/lib/presentation/pages/offers_page.dart +++ b/lib/presentation/pages/offers_page.dart @@ -23,6 +23,7 @@ import 'package:proxibuy/presentation/pages/reserved_list_page.dart'; import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart'; import 'package:proxibuy/presentation/widgets/gps_dialog.dart'; import 'package:proxibuy/presentation/widgets/notification_permission_dialog.dart'; +import 'package:proxibuy/presentation/widgets/notification_panel.dart'; import 'package:proxibuy/services/mqtt_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -44,6 +45,11 @@ class _OffersPageState extends State { bool _isSubscribedToOffers = false; bool _isGpsEnabled = false; bool _isConnectedToInternet = true; + // Notifications panel state + final GlobalKey _bellKey = GlobalKey(); + OverlayEntry? _notifOverlay; + bool _notifVisible = false; + int _notificationCount = 0; @override void initState() { @@ -52,6 +58,7 @@ class _OffersPageState extends State { _initializePage(); _initConnectivityListener(); _fetchInitialReservations(); + _fetchNotificationCount(); } @override @@ -60,6 +67,7 @@ class _OffersPageState extends State { _mqttMessageSubscription?.cancel(); _locationTimer?.cancel(); _connectivitySubscription?.cancel(); + _removeNotificationOverlay(); super.dispose(); } @@ -227,8 +235,8 @@ class _OffersPageState extends State { final payload = { "userID": userID, - "lat":32.6685, - "lng": 51.6826 + "lat": position.latitude, + "lng": position.longitude }; mqttService.publish("proxybuy/sendGps", payload); @@ -288,6 +296,139 @@ class _OffersPageState extends State { } } + Future _fetchNotificationCount() async { + try { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'accessToken'); + if (token == null) { + if (mounted) setState(() => _notificationCount = 0); + return; + } + final dio = Dio(); + final response = await dio.get( + 'https://proxybuy.liara.run/notify/get', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + if (!mounted) return; + if (response.statusCode == 200) { + final List data = response.data['data'] ?? []; + // Filter only active notifications (Status: true) + final activeNotifications = data.where((item) => item['Status'] == true).toList(); + setState(() => _notificationCount = activeNotifications.length); + } else { + setState(() => _notificationCount = 0); + } + } catch (_) { + if (mounted) setState(() => _notificationCount = 0); + } + } + + void _showNotificationOverlay() { + if (_notifOverlay != null) return; + + final overlay = Overlay.of(context); + final renderBox = _bellKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final bellSize = renderBox.size; + final bellPosition = renderBox.localToGlobal(Offset.zero); + + final screenSize = MediaQuery.of(context).size; + final panelWidth = screenSize.width.clamp(0, 360); + final width = (panelWidth > 320 ? 320.0 : panelWidth - 24).toDouble(); + final top = bellPosition.dy + bellSize.height; // stick to icon + final tentativeLeft = bellPosition.dx + bellSize.width - width; + final double left = tentativeLeft.clamp(8.0, screenSize.width - width - 8.0).toDouble(); + + _notifOverlay = OverlayEntry( + builder: (ctx) { + return Stack( + children: [ + // Tap outside to close + Positioned.fill( + child: GestureDetector( + onTap: _hideNotificationOverlay, + behavior: HitTestBehavior.opaque, + child: const SizedBox.shrink(), + ), + ), + Positioned( + top: top, + left: left, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.9, end: 1.0), + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutBack, + builder: (context, scale, child) { + // Ensure scale is valid + final validScale = scale.clamp(0.0, 1.0); + return Opacity( + opacity: validScale, + child: Transform.scale( + scale: validScale, + alignment: Alignment.topLeft, + child: Material( + color: Colors.transparent, + child: Container( + width: width, + constraints: const BoxConstraints( + maxHeight: 250, + minWidth: 370, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 24, + spreadRadius: 4, + offset: const Offset(0, 8), + ), + ], + ), + child: Directionality( + textDirection: TextDirection.rtl, + child: NotificationPanel( + onClose: _hideNotificationOverlay, + onListChanged: () { + _fetchNotificationCount(); + }, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + + overlay.insert(_notifOverlay!); + setState(() => _notifVisible = true); + } + + void _hideNotificationOverlay() { + _notifOverlay?.remove(); + _notifOverlay = null; + if (mounted) setState(() => _notifVisible = false); + } + + void _removeNotificationOverlay() { + _notifOverlay?.remove(); + _notifOverlay = null; + } + + Future _onRefresh() async { + await _sendLocationUpdate(); + await _fetchNotificationCount(); + await Future.delayed(const Duration(milliseconds: 300)); + } + Widget _buildFavoriteCategoriesSection() { return Padding( padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0), @@ -381,8 +522,51 @@ class _OffersPageState extends State { child: Assets.icons.logoWithName.svg(height: 40, width: 200), ), actions: [ - IconButton( - onPressed: () {}, icon: Assets.icons.notification.svg()), + // Notification bell with badge and overlay trigger + Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + IconButton( + key: _bellKey, + onPressed: () { + if (_notifVisible) { + _hideNotificationOverlay(); + } else { + _fetchNotificationCount(); + _showNotificationOverlay(); + } + }, + icon: Assets.icons.notification.svg(), + ), + if (_notificationCount > 0) + Positioned( + top: 3, + // in RTL, actions are on the left; badge at top-left of icon + right: 7, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: Center( + child: Text( + '$_notificationCount', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ], + ), BlocBuilder( builder: (context, state) { final reservedCount = state.reservedProductIds.length; @@ -445,16 +629,21 @@ class _OffersPageState extends State { const SizedBox(width: 8), ], ), - body: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFavoriteCategoriesSection(), - OffersView( - isGpsEnabled: _isGpsEnabled, - isConnectedToInternet: _isConnectedToInternet, - ), - ], + body: RefreshIndicator( + onRefresh: _onRefresh, + color: AppColors.active, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFavoriteCategoriesSection(), + OffersView( + isGpsEnabled: _isGpsEnabled, + isConnectedToInternet: _isConnectedToInternet, + ), + ], + ), ), ), ), diff --git a/lib/presentation/pages/product_detail_page.dart b/lib/presentation/pages/product_detail_page.dart index 310f2f1..ce5eda5 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,12 +187,47 @@ 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 { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'accessToken'); + + if (token == null) { + throw Exception('Authentication token not found!'); + } + + try { + final dio = Dio(); + 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) { + 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'); + } } void _launchMaps(double lat, double lon, String title) { @@ -384,19 +401,6 @@ class _ProductDetailViewState extends State { ), ); }, - child: Container( - width: 90, - height: 90, - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade400, width: 1.5), - ), - child: Padding( - padding: const EdgeInsets.all(9.0), - child: SvgPicture.asset(Assets.icons.addImg.path), - ), - ), ); } @@ -405,8 +409,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) @@ -663,7 +666,7 @@ class _ProductDetailViewState extends State { slideDirection: SlideDirection.up, separator: ':', style: const TextStyle( - fontSize: 50, + fontSize: 45, fontWeight: FontWeight.bold, color: AppColors.countdown, ), @@ -687,7 +690,21 @@ 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 SizedBox(); + } + 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/pages/splash_screen.dart b/lib/presentation/pages/splash_screen.dart index cf5648b..de16d76 100644 --- a/lib/presentation/pages/splash_screen.dart +++ b/lib/presentation/pages/splash_screen.dart @@ -3,12 +3,14 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart'; import 'package:proxibuy/presentation/pages/onboarding_page.dart'; import 'package:proxibuy/presentation/pages/offers_page.dart'; import 'package:proxibuy/core/gen/assets.gen.dart'; import 'package:proxibuy/services/mqtt_service.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -27,10 +29,25 @@ class _SplashScreenState extends State { super.initState(); Future.delayed(const Duration(seconds: 2), _startProcess); } + + Future getFcmToken() async { + FirebaseMessaging messaging = FirebaseMessaging.instance; + try { + String? token = await messaging.getToken(); + debugPrint("🔥 Firebase Messaging Token: $token"); + return token; + } catch(e) { + debugPrint("Error getting FCM token: $e"); + return null; + } + } + Future _startProcess() async { if (!mounted) return; + await _requestPermissions(); + final hasInternet = await _checkInternet(); if (!hasInternet) { setState(() { @@ -59,12 +76,19 @@ class _SplashScreenState extends State { }); return; } + + final String? fcmToken = await getFcmToken(); final mqttService = context.read(); final storage = const FlutterSecureStorage(); final token = await storage.read(key: 'accessToken'); if (token != null && token.isNotEmpty) { + + if (fcmToken != null) { + context.read().add(SendFcmTokenEvent(fcmToken: fcmToken)); + } + if (mqttService.isConnected) { _navigateToOffers(); return; @@ -186,4 +210,13 @@ class _SplashScreenState extends State { ), ); } + + Future _requestPermissions() async { + await Permission.notification.request(); + + var status = await Permission.location.request(); + if (status.isGranted) { + await Permission.locationAlways.request(); + } + } } \ No newline at end of file diff --git a/lib/presentation/widgets/notification_panel.dart b/lib/presentation/widgets/notification_panel.dart new file mode 100644 index 0000000..f7cbb61 --- /dev/null +++ b/lib/presentation/widgets/notification_panel.dart @@ -0,0 +1,262 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:intl/intl.dart'; +import 'package:proxibuy/core/config/app_colors.dart'; +import 'package:proxibuy/data/models/notification_model.dart'; +import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart'; +import 'package:proxibuy/presentation/offer/bloc/offer_state.dart'; +import 'package:proxibuy/presentation/pages/product_detail_page.dart'; + +class NotificationPanel extends StatefulWidget { + final VoidCallback onClose; + final VoidCallback? onListChanged; + + const NotificationPanel({super.key, required this.onClose, this.onListChanged}); + + @override + State createState() => _NotificationPanelState(); +} + +class _NotificationPanelState extends State { + List _notifications = []; + bool _isLoading = true; + String _errorMessage = ''; + final Dio _dio = Dio(); + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + @override + void initState() { + super.initState(); + _fetchNotifications(); + } + + Future _fetchNotifications() async { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'برای مشاهده اعلان‌ها، لطفا ابتدا وارد شوید.'; + }); + widget.onListChanged?.call(); + } + return; + } + + try { + final response = await _dio.get( + 'https://proxybuy.liara.run/notify/get', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && mounted) { + final List data = response.data['data'] ?? []; + setState(() { + _notifications = data.map((json) => NotificationModel.fromJson(json)).toList(); + _isLoading = false; + }); + widget.onListChanged?.call(); + } else if (response.statusCode == 201) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'اعلانی وجود ندارد'; + }); + widget.onListChanged?.call(); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'اتصال به سرور برقرار نشد.'; + }); + widget.onListChanged?.call(); + } + } + } + + Future _ignoreNotification(String notificationId) async { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('خطا: شما وارد حساب کاربری نشده‌اید.')), + ); + return; + } + + try { + final response = await _dio.get( + 'https://proxybuy.liara.run/notify/ignore/$notificationId', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && mounted) { + setState(() { + _notifications.removeWhere((n) => n.id == notificationId); + }); + widget.onListChanged?.call(); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response.data?['message'] ?? 'خطا در حذف اعلان.')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('خطا در ارتباط با سرور.')), + ); + debugPrint('Error ignoring notification: $e'); + } + } + } + + String _formatTimeAgo(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inSeconds < 60) { + return 'همین الان'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes} دقیقه قبل'; + } else if (difference.inHours < 24) { + return '${difference.inHours} ساعت قبل'; + } else if (difference.inDays < 7) { + return '${difference.inDays} روز قبل'; + } else { + return DateFormat('yyyy/MM/dd', 'fa').format(dateTime); + } + } + + void _navigateToOffer(NotificationModel notification) { + if (!notification.status) return; + + final offersState = context.read().state; + if (offersState is OffersLoadSuccess) { + try { + final offer = offersState.offers.firstWhere((o) => o.id == notification.discountId); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProductDetailPage(offer: offer), + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('این تخفیف در حال حاضر در دسترس نیست.')), + ); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('اطلاعات تخفیف‌ها هنوز بارگذاری نشده است.')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: _buildBody(), + ), + ], + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage.isNotEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(_errorMessage, textAlign: TextAlign.center), + ), + ); + } + if (_notifications.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('اعلانی وجود ندارد.', textAlign: TextAlign.center), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 0.0), + itemCount: _notifications.length, + itemBuilder: (context, index) { + return _buildNotificationCard(_notifications[index]); + }, + ); + } + + Widget _buildNotificationCard(NotificationModel notification) { + final bool isExpired = !notification.status; + final Color textColor = isExpired ? Colors.grey.shade600 : Colors.black; + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notification.description, + style: TextStyle(fontSize: 15, height: 1.6, color: textColor), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + _formatTimeAgo(notification.createdAt.toLocal()), + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + if (isExpired) + ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade300, + foregroundColor: Colors.grey.shade700, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + child: const Text('تخفیف تمام شد'), + ) + else + Row( + children: [ + TextButton( + onPressed: () => _ignoreNotification(notification.id), + style: TextButton.styleFrom(foregroundColor: Colors.red.shade700), + child: const Text('بیخیال'), + ), + const SizedBox(width: 4), + ElevatedButton( + onPressed: () => _navigateToOffer(notification), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.backgroundConfirm, + foregroundColor: AppColors.selectedImg, + side: BorderSide(color: Colors.green.shade200), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + child: const Text('بزن بریم'), + ), + ], + ), + ], + ), + ], + ), + ); + } +} \ 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 e6cd6a7..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(), @@ -339,8 +405,8 @@ class _ReservedListItemCardState extends State { children: [ Container( padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 9, + horizontal: 15, + vertical: 12, ), decoration: BoxDecoration( color: AppColors.singleOfferType, @@ -351,7 +417,8 @@ class _ReservedListItemCardState extends State { style: const TextStyle( color: Colors.white, fontWeight: FontWeight.normal, - fontSize: 17, + fontSize: 13, + overflow: TextOverflow.ellipsis ), ), ), @@ -365,7 +432,7 @@ class _ReservedListItemCardState extends State { Text( '(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)', style: const TextStyle( - fontSize: 14, + fontSize: 12, color: AppColors.singleOfferType, fontWeight: FontWeight.normal, ), @@ -374,7 +441,7 @@ class _ReservedListItemCardState extends State { Text( formatCurrency.format(widget.offer.originalPrice), style: TextStyle( - fontSize: 14, + fontSize: 13, color: Colors.grey.shade600, decoration: TextDecoration.lineThrough, ), @@ -386,7 +453,7 @@ class _ReservedListItemCardState extends State { '${formatCurrency.format(widget.offer.finalPrice)} تومان', style: const TextStyle( color: AppColors.singleOfferType, - fontSize: 18, + fontSize: 15, fontWeight: FontWeight.bold, ), ), @@ -394,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/lib/services/background_service.dart b/lib/services/background_service.dart new file mode 100644 index 0000000..0ea7f04 --- /dev/null +++ b/lib/services/background_service.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:flutter_background_service_android/flutter_background_service_android.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; + +const notificationChannelId = 'proxibuy_foreground_service'; +const notificationId = 888; + +@pragma('vm:entry-point') +void onStart(ServiceInstance service) async { + DartPluginRegistrant.ensureInitialized(); + + final MqttService mqttService = MqttService(); + const storage = FlutterSecureStorage(); + + if (service is AndroidServiceInstance) { + service.setForegroundNotificationInfo( + title: "ProxiBuy فعال است", + content: "در حال جستجو برای تخفیف های اطراف شما...", + ); + + service.on('stopService').listen((event) { + service.stopSelf(); + }); + } + + Future sendGpsData() async { + var locationStatus = await Permission.location.status; + if (!locationStatus.isGranted) { + debugPrint("Background Service: Location permission not granted."); + return; + } + + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high); + final token = await storage.read(key: 'accessToken'); + final userID = await storage.read(key: 'userID'); + + if (token != null && userID != null) { + if (!mqttService.isConnected) { + await mqttService.connect(token); + final topic = 'user-proxybuy/$userID'; + mqttService.subscribe(topic); + } + + if (mqttService.isConnected) { + final payload = { + "userID": userID, + "lat": position.latitude, + "lng": position.longitude + }; + mqttService.publish("proxybuy/sendGps", payload); + debugPrint("Background Service: GPS sent successfully."); + } + } + } catch (e) { + debugPrint("❌ Background Service Error: $e"); + } + } + + mqttService.messages.listen((data) { + service.invoke('update', {'offers': data}); + }); + + service.on('force_refresh').listen((event) async { + debugPrint("✅ Background Service: Received force_refresh event."); + await sendGpsData(); + }); + + Timer.periodic(const Duration(seconds: 30), (timer) async { + debugPrint("✅ Background Service: Sending location via periodic timer..."); + await sendGpsData(); + }); +} + +Future initializeService() async { + final service = FlutterBackgroundService(); + + const AndroidNotificationChannel channel = AndroidNotificationChannel( + notificationChannelId, + 'ProxiBuy Background Service', + description: 'This channel is used for location service notifications.', + importance: Importance.low, + ); + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: onStart, + isForegroundMode: true, + autoStart: true, + notificationChannelId: notificationChannelId, + initialNotificationTitle: 'ProxiBuy فعال است', + initialNotificationContent: 'در حال جستجو برای تخفیف‌های اطراف شما...', + foregroundServiceNotificationId: notificationId, + ), + iosConfiguration: IosConfiguration( + autoStart: true, + onForeground: onStart, + ), + ); +} \ No newline at end of file diff --git a/lib/services/background_tasks.dart b/lib/services/background_tasks.dart new file mode 100644 index 0000000..79511cf --- /dev/null +++ b/lib/services/background_tasks.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:flutter_background_service_android/flutter_background_service_android.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; + +const notificationChannelId = 'proxibuy_foreground_service'; +const notificationId = 888; + +@pragma('vm:entry-point') +void onStart(ServiceInstance service) async { + DartPluginRegistrant.ensureInitialized(); + + final MqttService mqttService = MqttService(); + const storage = FlutterSecureStorage(); + + if (service is AndroidServiceInstance) { + service.setForegroundNotificationInfo( + title: "ProxiBuy فعال است", + content: "در حال یافتن تخفیف های اطراف شما...", + ); + + service.on('setAsForeground').listen((event) { + service.setAsForegroundService(); + }); + + service.on('setAsBackground').listen((event) { + service.setAsBackgroundService(); + }); + } + + service.on('stopService').listen((event) { + service.stopSelf(); + }); + + Timer.periodic(const Duration(seconds: 30), (timer) async { + debugPrint("✅ Background Service is running and sending location..."); + + var locationStatus = await Permission.location.status; + var locationAlwaysStatus = await Permission.locationAlways.status; + if (!locationStatus.isGranted || !locationAlwaysStatus.isGranted) { + debugPrint("Background Service: Permissions not granted. Task skipped."); + return; + } + + final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + final token = await storage.read(key: 'accessToken'); + final userID = await storage.read(key: 'userID'); + + if (token != null && token.isNotEmpty && userID != null) { + if (!mqttService.isConnected) { + await mqttService.connect(token); + } + + if (mqttService.isConnected) { + final payload = {"userID": userID, "lat": position.latitude, "lng": position.longitude}; + mqttService.publish("proxybuy/sendGps", payload); + debugPrint("Background Service: GPS sent successfully."); + } + } + }); +} + +Future initializeService() async { + final service = FlutterBackgroundService(); + + const AndroidNotificationChannel channel = AndroidNotificationChannel( + notificationChannelId, + 'ProxiBuy Background Service', + description: 'This channel is used for location service notifications.', + importance: Importance.low, + ); + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.createNotificationChannel(channel); + + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: onStart, + isForegroundMode: true, + autoStart: true, + notificationChannelId: notificationChannelId, + initialNotificationTitle: 'ProxiBuy فعال است', + initialNotificationContent: 'در حال جستجو برای تخفیف‌های اطراف شما...', + foregroundServiceNotificationId: notificationId, + ), + iosConfiguration: IosConfiguration( + autoStart: true, + onForeground: onStart, + ), + ); +} \ No newline at end of file diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart index 91dffbf..6b85d42 100644 --- a/lib/services/mqtt_service.dart +++ b/lib/services/mqtt_service.dart @@ -3,12 +3,13 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:mqtt_client/mqtt_server_client.dart'; class MqttService { MqttServerClient? client; - final String server = '5.75.197.180'; + final String server = '62.60.214.99'; final int port = 1883; final StreamController> _messageStreamController = StreamController.broadcast(); @@ -20,7 +21,9 @@ class MqttService { } Future connect(String token) async { - final String clientId = + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + final String clientId = userID?? 'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0'); final String username = 'ignored'; final String password = token; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3a6fae2..2137cd3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,7 +11,10 @@ import connectivity_plus import file_selector_macos import firebase_auth import firebase_core +import firebase_crashlytics +import firebase_messaging import firebase_storage +import flutter_local_notifications import flutter_localization import flutter_secure_storage_macos import geolocator_apple @@ -28,7 +31,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) diff --git a/pubspec.lock b/pubspec.lock index ace5377..df92308 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.4.5" + animations: + dependency: "direct main" + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" archive: dependency: transitive description: @@ -521,6 +529,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.24.1" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "662ae6443da91bca1fb0be8aeeac026fa2975e8b7ddfca36e4d90ebafa35dde1" + url: "https://pub.dev" + source: hosted + version: "4.3.10" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "7222a8a40077c79f6b8b3f3439241c9f2b34e9ddfde8381ffc512f7b2e61f7eb" + url: "https://pub.dev" + source: hosted + version: "3.8.10" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" firebase_storage: dependency: "direct main" description: @@ -566,6 +614,38 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.2" + flutter_background_service: + dependency: "direct main" + description: + name: flutter_background_service + sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_background_service_android: + dependency: "direct main" + description: + name: flutter_background_service_android + sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87 + url: "https://pub.dev" + source: hosted + version: "6.3.1" + flutter_background_service_ios: + dependency: transitive + description: + name: flutter_background_service_ios + sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + flutter_background_service_platform_interface: + dependency: transitive + description: + name: flutter_background_service_platform_interface + sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41 + url: "https://pub.dev" + source: hosted + version: "5.1.2" flutter_bloc: dependency: "direct main" description: @@ -614,6 +694,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "20ca0a9c82ce0c855ac62a2e580ab867f3fbea82680a90647f7953832d0850ae" + url: "https://pub.dev" + source: hosted + version: "19.4.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98 + url: "https://pub.dev" + source: hosted + version: "1.0.2" flutter_localization: dependency: "direct main" description: @@ -635,6 +747,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: @@ -1121,10 +1241,10 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f" + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 url: "https://pub.dev" source: hosted - version: "12.0.0+1" + version: "12.0.1" permission_handler_android: dependency: transitive description: @@ -1333,6 +1453,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + skeletons: + dependency: "direct main" + description: + name: skeletons + sha256: "5b2d08ae7f908ee1f7007ca99f8dcebb4bfc1d3cb2143dec8d112a5be5a45c8f" + url: "https://pub.dev" + source: hosted + version: "0.0.3" sky_engine: dependency: transitive description: flutter @@ -1466,6 +1594,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.5" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" timing: dependency: transitive description: @@ -1642,6 +1778,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "746a50c535af15b6dc225abbd9b52ab272bcd292c535a104c54b5bc02609c38a" + url: "https://pub.dev" + source: hosted + version: "0.7.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e35f4a8..8678823 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: flutter_gen: ^5.10.0 country_picker: ^2.0.27 geolocator: ^14.0.1 - permission_handler: ^12.0.0+1 + permission_handler: ^12.0.1 cached_network_image: ^3.4.1 collection: ^1.19.1 shared_preferences: ^2.5.3 @@ -62,6 +62,15 @@ dependencies: dart_jsonwebtoken: ^3.2.0 audioplayers: ^6.5.0 intl: ^0.19.0 + flutter_background_service: ^5.1.0 + flutter_background_service_android: ^6.3.1 + flutter_local_notifications: ^19.4.0 + workmanager: ^0.7.0 + firebase_messaging: ^15.2.10 + firebase_crashlytics: ^4.3.10 + flutter_rating_bar: ^4.0.1 + animations: ^2.0.11 + skeletons: ^0.0.3 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8930e6e..c2255fb 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -19,6 +19,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES)