From f4cd446cde6d6f677a5cf2b89dd67d0797ea4c25 Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Sun, 3 Aug 2025 15:44:57 +0330 Subject: [PATCH 1/6] location background activity --- android/app/build.gradle.kts | 18 ++- android/app/src/main/AndroidManifest.xml | 27 ++-- lib/core/config/api_config.dart | 7 +- lib/data/models/offer_model.dart | 26 ++- lib/main.dart | 42 +++-- lib/presentation/auth/bloc/auth_bloc.dart | 75 ++++++--- lib/presentation/auth/bloc/auth_event.dart | 5 + lib/presentation/pages/offers_page.dart | 153 ++++-------------- lib/presentation/pages/splash_screen.dart | 33 ++++ .../widgets/reserved_list_item_card.dart | 13 +- lib/services/background_service.dart | 107 ++++++++++++ lib/services/background_tasks.dart | 100 ++++++++++++ lib/services/mqtt_service.dart | 97 ++++------- macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 124 +++++++++++++- pubspec.yaml | 8 +- windows/flutter/generated_plugins.cmake | 1 + 17 files changed, 590 insertions(+), 252 deletions(-) create mode 100644 lib/services/background_service.dart create mode 100644 lib/services/background_tasks.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index eaf1789..ed0357e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,29 +14,26 @@ android { ndkVersion = flutter.ndkVersion 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..7b2fd80 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,18 +1,19 @@ + + - - + + - - + - + + + - + - + \ No newline at end of file diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart index 8a56d7d..4f08183 100644 --- a/lib/core/config/api_config.dart +++ b/lib/core/config/api_config.dart @@ -5,6 +5,7 @@ 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"; -} \ No newline at end of file + static const String addReservation = "/reservation/add"; + static const String getReservations = "/reservation/get"; + static const String updateFcmToken = "/user/firebaseUpdate"; +} 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..e8976b8 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'; @@ -13,15 +12,35 @@ import 'package:proxibuy/presentation/auth/bloc/auth_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; + } + + // FirebaseMessaging.instance.onTokenRefresh + // .listen((fcmToken) { + // // TODO: If necessary send token to application server. + // // Note: This callback is fired at each app startup and whenever a new + // // token is generated. + // }) + // .onError((err) { + // // Error getting token. + // }); + WidgetsFlutterBinding.ensureInitialized(); + + await initializeService(); + HttpOverrides.global = MyHttpOverrides(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); Animate.restartOnHotReload = true; runApp(const MyApp()); @@ -34,22 +53,15 @@ 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(), ), - ], child: MaterialApp( title: 'Proxibuy', @@ -128,4 +140,4 @@ class MyApp extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/presentation/auth/bloc/auth_bloc.dart b/lib/presentation/auth/bloc/auth_bloc.dart index fb9ab81..ec73fb1 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'; @@ -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()); @@ -52,10 +53,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 +68,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 +103,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 +125,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/pages/offers_page.dart b/lib/presentation/pages/offers_page.dart index bf97334..6f6e36b 100644 --- a/lib/presentation/pages/offers_page.dart +++ b/lib/presentation/pages/offers_page.dart @@ -1,3 +1,5 @@ +// lib/presentation/pages/offers_page.dart + import 'dart:async'; import 'package:collection/collection.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; @@ -5,6 +7,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/svg.dart'; import 'package:geolocator/geolocator.dart'; @@ -23,7 +26,6 @@ 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/services/mqtt_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class OffersPage extends StatefulWidget { @@ -38,10 +40,7 @@ class OffersPage extends StatefulWidget { class _OffersPageState extends State { List _selectedCategories = []; StreamSubscription? _locationServiceSubscription; - StreamSubscription? _mqttMessageSubscription; StreamSubscription? _connectivitySubscription; - Timer? _locationTimer; - bool _isSubscribedToOffers = false; bool _isGpsEnabled = false; bool _isConnectedToInternet = true; @@ -57,8 +56,6 @@ class _OffersPageState extends State { @override void dispose() { _locationServiceSubscription?.cancel(); - _mqttMessageSubscription?.cancel(); - _locationTimer?.cancel(); _connectivitySubscription?.cancel(); super.dispose(); } @@ -67,7 +64,8 @@ class _OffersPageState extends State { final connectivityResult = await Connectivity().checkConnectivity(); if (!mounted) return; setState(() { - _isConnectedToInternet = !connectivityResult.contains(ConnectivityResult.none); + _isConnectedToInternet = + !connectivityResult.contains(ConnectivityResult.none); }); } @@ -80,10 +78,37 @@ class _OffersPageState extends State { } }); } - await _loadPreferences(); _initLocationListener(); - _subscribeToUserOffersOnLoad(); + _listenToBackgroundService(); + } + + void _listenToBackgroundService() { + FlutterBackgroundService().on('update').listen((event) { + if (event == null || event['offers'] == null) return; + + final data = event['offers']['data']; + if (data == null || data is! List) { + if (mounted) { + context.read().add(const OffersReceivedFromMqtt([])); + } + return; + } + + try { + List offers = data + .whereType>() + .map((json) => OfferModel.fromJson(json)) + .toList(); + + if (mounted) { + context.read().add(OffersReceivedFromMqtt(offers)); + } + } catch (e, stackTrace) { + debugPrint("❌ Error parsing offers from Background Service: $e"); + debugPrint(stackTrace.toString()); + } + }); } void _initConnectivityListener() { @@ -143,14 +168,6 @@ class _OffersPageState extends State { } } - Future _subscribeToUserOffersOnLoad() async { - final storage = const FlutterSecureStorage(); - final userID = await storage.read(key: 'userID'); - if (userID != null && mounted) { - _subscribeToUserOffers(userID); - } - } - void _initLocationListener() { _checkInitialGpsStatus(); _locationServiceSubscription = @@ -160,11 +177,7 @@ class _OffersPageState extends State { setState(() { _isGpsEnabled = isEnabled; }); - if (isEnabled) { - _startSendingLocationUpdates(); - } else { - debugPrint("❌ Location Service Disabled. Stopping updates."); - _locationTimer?.cancel(); + if (!isEnabled) { context.read().add(ClearOffers()); } } @@ -177,105 +190,9 @@ class _OffersPageState extends State { setState(() { _isGpsEnabled = status; }); - if (_isGpsEnabled) { - _startSendingLocationUpdates(); - } } } - void _startSendingLocationUpdates() { - debugPrint("🚀 Starting periodic location updates."); - _locationTimer?.cancel(); - _locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) { - _sendLocationUpdate(); - }); - _sendLocationUpdate(); - } - - Future _sendLocationUpdate() async { - if (!_isConnectedToInternet || !_isGpsEnabled) return; - - final mqttService = context.read(); - if (!mqttService.isConnected) { - debugPrint("⚠️ MQTT not connected in OffersPage. Cannot send location."); - return; - } - - try { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - } - - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - debugPrint("🚫 Location permission denied by user."); - return; - } - - final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); - - const storage = FlutterSecureStorage(); - final userID = await storage.read(key: 'userID'); - - if (userID == null) { - debugPrint("⚠️ UserID not found. Cannot send location."); - return; - } - - final payload = { - "userID": userID, - "lat":32.6685, - "lng": 51.6826 - }; - - mqttService.publish("proxybuy/sendGps", payload); - } catch (e) { - debugPrint("❌ Error sending location update in OffersPage: $e"); - } - } - - void _subscribeToUserOffers(String userID) { - if (_isSubscribedToOffers) return; - - final mqttService = context.read(); - if (!mqttService.isConnected) { - debugPrint("⚠️ Cannot subscribe. MQTT client is not connected."); - return; - } - - final topic = 'user-proxybuy/$userID'; - mqttService.subscribe(topic); - _isSubscribedToOffers = true; - - _mqttMessageSubscription = mqttService.messages.listen((message) { - final data = message['data']; - - if (data == null || data is! List) { - if (mounted) { - context.read().add(const OffersReceivedFromMqtt([])); - } - return; - } - - try { - List offers = data - .whereType>() - .map((json) => OfferModel.fromJson(json)) - .toList(); - - if (mounted) { - context.read().add(OffersReceivedFromMqtt(offers)); - } - } catch (e, stackTrace) { - debugPrint("❌ Error parsing offers from MQTT: $e"); - debugPrint(stackTrace.toString()); - } - }); - } - Future _loadPreferences() async { final prefs = await SharedPreferences.getInstance(); final savedCategories = 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/reserved_list_item_card.dart b/lib/presentation/widgets/reserved_list_item_card.dart index e6cd6a7..30dd7f8 100644 --- a/lib/presentation/widgets/reserved_list_item_card.dart +++ b/lib/presentation/widgets/reserved_list_item_card.dart @@ -339,8 +339,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 +351,8 @@ class _ReservedListItemCardState extends State { style: const TextStyle( color: Colors.white, fontWeight: FontWeight.normal, - fontSize: 17, + fontSize: 13, + overflow: TextOverflow.ellipsis ), ), ), @@ -365,7 +366,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 +375,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 +387,7 @@ class _ReservedListItemCardState extends State { '${formatCurrency.format(widget.offer.finalPrice)} تومان', style: const TextStyle( color: AppColors.singleOfferType, - fontSize: 18, + fontSize: 15, fontWeight: FontWeight.bold, ), ), diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart new file mode 100644 index 0000000..d71abf6 --- /dev/null +++ b/lib/services/background_service.dart @@ -0,0 +1,107 @@ +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(); + }); + } + + mqttService.messages.listen((data) { + service.invoke('update', {'offers': data}); + }); + + Timer.periodic(const Duration(seconds: 30), (timer) async { + debugPrint("✅ Background Service: Sending location..."); + + 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"); + } + }); +} + +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..8cac638 100644 --- a/lib/services/mqtt_service.dart +++ b/lib/services/mqtt_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:mqtt_client/mqtt_client.dart'; @@ -10,18 +9,21 @@ class MqttService { MqttServerClient? client; final String server = '5.75.197.180'; final int port = 1883; - final StreamController> _messageStreamController = - StreamController.broadcast(); - + + final StreamController> _messageStreamController = StreamController.broadcast(); Stream> get messages => _messageStreamController.stream; + + Completer>? _firstMessageCompleter; - bool get isConnected { - return client?.connectionStatus?.state == MqttConnectionState.connected; + bool get isConnected => client?.connectionStatus?.state == MqttConnectionState.connected; + + Future> awaitFirstMessage() { + _firstMessageCompleter = Completer>(); + return _firstMessageCompleter!.future; } Future connect(String token) async { - final String clientId = - 'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0'); + final String clientId = 'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0'); final String username = 'ignored'; final String password = token; @@ -31,10 +33,6 @@ class MqttService { client!.autoReconnect = true; client!.setProtocolV311(); - debugPrint('--- [MQTT] Attempting to connect...'); - debugPrint('--- [MQTT] Server: $server:$port'); - debugPrint('--- [MQTT] ClientID: $clientId'); - final connMessage = MqttConnectMessage() .withClientIdentifier(clientId) .startClean() @@ -46,86 +44,57 @@ class MqttService { debugPrint('✅ [MQTT] Connected successfully.'); client!.updates!.listen((List> c) { final MqttPublishMessage recMess = c[0].payload as MqttPublishMessage; - - final String payload = - MqttPublishPayload.bytesToStringAsString(recMess.payload.message); - - debugPrint('<<<<< [MQTT] Received Data <<<<<'); - debugPrint('<<<<< [MQTT] Topic: ${c[0].topic}'); - debugPrint('<<<<< [MQTT] Payload as String: $payload'); - debugPrint('<<<<< ======================== <<<<<'); + final String payload = MqttPublishPayload.bytesToStringAsString(recMess.payload.message); try { final Map jsonPayload = json.decode(payload); - _messageStreamController.add(jsonPayload); + + if (!_messageStreamController.isClosed) { + _messageStreamController.add(jsonPayload); + } + + if (_firstMessageCompleter != null && !_firstMessageCompleter!.isCompleted) { + _firstMessageCompleter!.complete(jsonPayload); + } } catch (e) { - debugPrint("❌ [MQTT] Error decoding received JSON: $e"); + debugPrint("❌ [MQTT] Error decoding JSON: $e"); + if (_firstMessageCompleter != null && !_firstMessageCompleter!.isCompleted) { + _firstMessageCompleter!.completeError(e); + } } }); }; - client!.onDisconnected = () { - debugPrint('❌ [MQTT] Disconnected.'); - }; + client!.onDisconnected = () => debugPrint('❌ [MQTT] Disconnected.'); + client!.onSubscribed = (String topic) => debugPrint('✅ [MQTT] Subscribed to topic: $topic'); - client!.onAutoReconnect = () { - debugPrint('↪️ [MQTT] Auto-reconnecting...'); - }; - - client!.onAutoReconnected = () { - debugPrint('✅ [MQTT] Auto-reconnected successfully.'); - }; - - client!.onSubscribed = (String topic) { - debugPrint('✅ [MQTT] Subscribed to topic: $topic'); - }; - - client!.pongCallback = () { - debugPrint('🏓 [MQTT] Ping response received'); - }; - try { await client!.connect(); - } on NoConnectionException catch (e) { - debugPrint('❌ [MQTT] Connection failed - No Connection Exception: $e'); - client?.disconnect(); - } on SocketException catch (e) { - debugPrint('❌ [MQTT] Connection failed - Socket Exception: $e'); - client?.disconnect(); } catch (e) { - debugPrint('❌ [MQTT] Connection failed - General Exception: $e'); + debugPrint('❌ [MQTT] Connection failed: $e'); client?.disconnect(); + if (_firstMessageCompleter != null && !_firstMessageCompleter!.isCompleted) { + _firstMessageCompleter!.completeError(e); + } } } - + void subscribe(String topic) { if (isConnected) { client?.subscribe(topic, MqttQos.atLeastOnce); - } else { - debugPrint("⚠️ [MQTT] Cannot subscribe. Client is not connected."); } } void publish(String topic, Map message) { if (isConnected) { final builder = MqttClientPayloadBuilder(); - final payloadString = json.encode(message); - builder.addString(payloadString); - - debugPrint('>>>>> [MQTT] Publishing Data >>>>>'); - debugPrint('>>>>> [MQTT] Topic: $topic'); - debugPrint('>>>>> [MQTT] Payload: $payloadString'); - debugPrint('>>>>> ======================= >>>>>'); - + builder.addString(json.encode(message)); client?.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); - } else { - debugPrint("⚠️ [MQTT] Cannot publish. Client is not connected."); } } void dispose() { - debugPrint("--- [MQTT] Disposing MQTT Service."); - _messageStreamController.close(); client?.disconnect(); + _messageStreamController.close(); } -} \ No newline at end of file +} \ No newline at end of file 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..9f7b7ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -521,6 +521,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 +606,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 +686,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: @@ -1121,10 +1225,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: @@ -1466,6 +1570,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 +1754,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..0f8ac88 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,12 @@ 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 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) -- 2.40.1 From 608222e8a32b340e2c53b4f2cb1b1de3096345ea Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Mon, 4 Aug 2025 10:35:28 +0330 Subject: [PATCH 2/6] add refresh offer_page --- android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 1 + lib/presentation/auth/bloc/auth_bloc.dart | 3 +- lib/presentation/auth/bloc/auth_state.dart | 2 + .../pages/notification_preferences_page.dart | 281 +++++++++--------- lib/presentation/pages/offers_page.dart | 79 ++++- lib/services/background_service.dart | 22 +- 7 files changed, 226 insertions(+), 164 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ed0357e..4048b18 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,7 +11,7 @@ plugins { android { namespace = "com.example.proxibuy" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { isCoreLibraryDesugaringEnabled = true diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7b2fd80..28af001 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/lib/presentation/auth/bloc/auth_bloc.dart b/lib/presentation/auth/bloc/auth_bloc.dart index ec73fb1..99a9c78 100644 --- a/lib/presentation/auth/bloc/auth_bloc.dart +++ b/lib/presentation/auth/bloc/auth_bloc.dart @@ -13,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( @@ -44,6 +44,7 @@ class AuthBloc extends Bloc { } } + Future _onSendOTP(SendOTPEvent event, Emitter emit) async { emit(AuthLoading()); try { 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/pages/notification_preferences_page.dart b/lib/presentation/pages/notification_preferences_page.dart index 210712c..0e27f2c 100644 --- a/lib/presentation/pages/notification_preferences_page.dart +++ b/lib/presentation/pages/notification_preferences_page.dart @@ -1,3 +1,4 @@ + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -130,7 +131,7 @@ class _NotificationPreferencesPageState const SizedBox(width: 8), ], ), - body: BlocListener( listener: (context, state) async { if (state.submissionSuccess) { @@ -157,7 +158,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 +177,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 6f6e36b..0ba60b7 100644 --- a/lib/presentation/pages/offers_page.dart +++ b/lib/presentation/pages/offers_page.dart @@ -1,4 +1,3 @@ -// lib/presentation/pages/offers_page.dart import 'dart:async'; import 'package:collection/collection.dart'; @@ -205,6 +204,39 @@ class _OffersPageState extends State { } } + Future _handleRefresh() { + final completer = Completer(); + final service = FlutterBackgroundService(); + + final timeout = Timer(const Duration(seconds: 20), () { + if (!completer.isCompleted) { + completer.completeError('Request timed out.'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Request timed out. Please try again.')), + ); + } + } + }); + + final StreamSubscription?> subscription = + service.on('update').listen((event) { + if (!completer.isCompleted) { + completer.complete(); + } + }); + + completer.future.whenComplete(() { + subscription.cancel(); + timeout.cancel(); + }); + + service.invoke('force_refresh'); + + return completer.future; + } + Widget _buildFavoriteCategoriesSection() { return Padding( padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0), @@ -220,7 +252,7 @@ class _OffersPageState extends State { ), TextButton( onPressed: () async { - await Navigator.of(context).push( + final result = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => const NotificationPreferencesPage( loadFavoritesOnStart: true, @@ -234,7 +266,11 @@ class _OffersPageState extends State { .read() .add(ResetSubmissionStatus()); - _loadPreferences(); + await _loadPreferences(); + + if (result == true) { + _handleRefresh(); + } }, child: Row( children: [ @@ -362,16 +398,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: _handleRefresh, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFavoriteCategoriesSection(), + OffersView( + isGpsEnabled: _isGpsEnabled, + isConnectedToInternet: _isConnectedToInternet, + selectedCategories: _selectedCategories, + ), + ], + ), ), ), ), @@ -382,11 +423,13 @@ class _OffersPageState extends State { class OffersView extends StatelessWidget { final bool isGpsEnabled; final bool isConnectedToInternet; + final List selectedCategories; const OffersView({ super.key, required this.isGpsEnabled, required this.isConnectedToInternet, + required this.selectedCategories, }); @override @@ -418,7 +461,13 @@ class OffersView extends StatelessWidget { } if (state is OffersLoadSuccess) { - if (state.offers.isEmpty) { + final filteredOffers = selectedCategories.isEmpty + ? state.offers + : state.offers + .where((offer) => selectedCategories.contains(offer.category)) + .toList(); + + if (filteredOffers.isEmpty) { return const SizedBox( height: 300, child: Center( @@ -434,7 +483,7 @@ class OffersView extends StatelessWidget { } final groupedOffers = groupBy( - state.offers, + filteredOffers, (OfferModel offer) => offer.category, ); final categories = groupedOffers.keys.toList(); diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index d71abf6..0ea7f04 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -30,13 +30,7 @@ void onStart(ServiceInstance service) async { }); } - mqttService.messages.listen((data) { - service.invoke('update', {'offers': data}); - }); - - Timer.periodic(const Duration(seconds: 30), (timer) async { - debugPrint("✅ Background Service: Sending location..."); - + Future sendGpsData() async { var locationStatus = await Permission.location.status; if (!locationStatus.isGranted) { debugPrint("Background Service: Location permission not granted."); @@ -69,6 +63,20 @@ void onStart(ServiceInstance service) async { } 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(); }); } -- 2.40.1 From ce62c567c5bcc8a29ec8ab8cec6ba300a3c5ce91 Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Mon, 4 Aug 2025 12:15:17 +0330 Subject: [PATCH 3/6] added comment --- lib/core/config/api_config.dart | 4 +- lib/data/models/comment_model.dart | 17 +- lib/main.dart | 16 +- .../comment/bloc/comment_bloc.dart | 81 ++++++ .../comment/bloc/comment_event.dart | 26 ++ .../comment/bloc/comment_state.dart | 23 ++ lib/presentation/pages/add_photo_screen.dart | 1 - lib/presentation/pages/comment_page.dart | 273 ++++++++++++++++++ .../pages/product_detail_page.dart | 90 ++++-- .../pages/reservation_details_screen.dart | 61 +++- .../widgets/reserved_list_item_card.dart | 228 ++++++++++----- .../widgets/user_comment_card.dart | 2 +- pubspec.lock | 8 + pubspec.yaml | 1 + 14 files changed, 694 insertions(+), 137 deletions(-) create mode 100644 lib/presentation/comment/bloc/comment_bloc.dart create mode 100644 lib/presentation/comment/bloc/comment_event.dart create mode 100644 lib/presentation/comment/bloc/comment_state.dart create mode 100644 lib/presentation/pages/comment_page.dart diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart index 4f08183..e1bc137 100644 --- a/lib/core/config/api_config.dart +++ b/lib/core/config/api_config.dart @@ -8,4 +8,6 @@ class ApiConfig { static const String addReservation = "/reservation/add"; static const String getReservations = "/reservation/get"; static const String updateFcmToken = "/user/firebaseUpdate"; -} + static const String addComment = "/comment/add"; + static const String getComments = "/comment/get/"; +} \ No newline at end of file diff --git a/lib/data/models/comment_model.dart b/lib/data/models/comment_model.dart index dd15047..3ef2ee7 100644 --- a/lib/data/models/comment_model.dart +++ b/lib/data/models/comment_model.dart @@ -23,13 +23,18 @@ class CommentModel extends Equatable { List get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls]; factory CommentModel.fromJson(Map json) { + final List images = (json['UserImages'] as List?) + ?.map((image) => image['Url'] as String) + .toList() ?? + []; + return CommentModel( - id: json['id'], - userName: json['userName'], - rating: (json['rating'] as num).toDouble(), - comment: json['comment'], - publishedAt: DateTime.parse(json['publishedAt']), - uploadedImageUrls: List.from(json['uploadedImageUrls'] ?? []), + id: json['ID'] ?? '', + userName: json['User']?['Name'] ?? 'کاربر ناشناس', + rating: (json['Score'] as num?)?.toDouble() ?? 0.0, + comment: json['Text'] ?? '', + publishedAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), + uploadedImageUrls: images, ); } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index e8976b8..160f894 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:proxibuy/core/config/http_overrides.dart'; import 'package:proxibuy/firebase_options.dart'; import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_bloc.dart'; // این ایمپورت اضافه شد import 'package:proxibuy/presentation/notification_preferences/bloc/notification_preferences_bloc.dart'; import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart'; import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart'; @@ -26,16 +27,6 @@ void main() async { return token; } - // FirebaseMessaging.instance.onTokenRefresh - // .listen((fcmToken) { - // // TODO: If necessary send token to application server. - // // Note: This callback is fired at each app startup and whenever a new - // // token is generated. - // }) - // .onError((err) { - // // Error getting token. - // }); - WidgetsFlutterBinding.ensureInitialized(); await initializeService(); @@ -62,6 +53,9 @@ class MyApp extends StatelessWidget { BlocProvider( create: (context) => NotificationPreferencesBloc(), ), + BlocProvider( + create: (context) => CommentBloc(), + ), ], child: MaterialApp( title: 'Proxibuy', @@ -140,4 +134,4 @@ class MyApp extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/presentation/comment/bloc/comment_bloc.dart b/lib/presentation/comment/bloc/comment_bloc.dart new file mode 100644 index 0000000..07fbfd3 --- /dev/null +++ b/lib/presentation/comment/bloc/comment_bloc.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:proxibuy/core/config/api_config.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_event.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_state.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:flutter/foundation.dart'; + +class CommentBloc extends Bloc { + late final Dio _dio; + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + CommentBloc() : super(CommentInitial()) { + _dio = Dio(); + _dio.interceptors.add( + LogInterceptor( + requestHeader: true, + requestBody: true, + responseBody: true, + responseHeader: false, + error: true, + logPrint: (obj) => debugPrint(obj.toString()), + ), + ); + on(_onSubmitComment); + } + + Future _onSubmitComment(SubmitComment event, Emitter emit) async { + if (event.text.isEmpty && event.score == 0) { + emit(const CommentSubmissionFailure("لطفا امتیاز یا نظری برای این تخفیف ثبت کنید.")); + return; + } + + emit(CommentSubmitting()); + try { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + emit(const CommentSubmissionFailure("شما وارد نشده‌اید.")); + return; + } + + final formData = FormData.fromMap({ + 'Discount': event.discountId, + 'Text': event.text, + 'Score': event.score, + }); + + for (File imageFile in event.images) { + formData.files.add(MapEntry( + 'Images', + await MultipartFile.fromFile( + imageFile.path, + filename: imageFile.path.split('/').last, + contentType: MediaType('image', 'jpeg'), + ), + )); + } + + final response = await _dio.post( + ApiConfig.baseUrl + ApiConfig.addComment, + data: formData, + options: Options( + headers: {'Authorization': 'Bearer $token'}, + ), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + emit(CommentSubmissionSuccess()); + } else { + emit(CommentSubmissionFailure(response.data['message'] ?? 'خطا در ارسال نظر')); + } + } on DioException catch (e) { + emit(CommentSubmissionFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور')); + } catch (e) { + emit(CommentSubmissionFailure('خطایی ناشناخته رخ داد: $e')); + } + } +} \ No newline at end of file diff --git a/lib/presentation/comment/bloc/comment_event.dart b/lib/presentation/comment/bloc/comment_event.dart new file mode 100644 index 0000000..1d73666 --- /dev/null +++ b/lib/presentation/comment/bloc/comment_event.dart @@ -0,0 +1,26 @@ +import 'dart:io'; +import 'package:equatable/equatable.dart'; + +abstract class CommentEvent extends Equatable { + const CommentEvent(); + + @override + List get props => []; +} + +class SubmitComment extends CommentEvent { + final String discountId; + final String text; + final double score; + final List images; + + const SubmitComment({ + required this.discountId, + required this.text, + required this.score, + required this.images, + }); + + @override + List get props => [discountId, text, score, images]; +} \ No newline at end of file diff --git a/lib/presentation/comment/bloc/comment_state.dart b/lib/presentation/comment/bloc/comment_state.dart new file mode 100644 index 0000000..84435b4 --- /dev/null +++ b/lib/presentation/comment/bloc/comment_state.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class CommentState extends Equatable { + const CommentState(); + + @override + List get props => []; +} + +class CommentInitial extends CommentState {} + +class CommentSubmitting extends CommentState {} + +class CommentSubmissionSuccess extends CommentState {} + +class CommentSubmissionFailure extends CommentState { + final String error; + + const CommentSubmissionFailure(this.error); + + @override + List get props => [error]; +} \ No newline at end of file diff --git a/lib/presentation/pages/add_photo_screen.dart b/lib/presentation/pages/add_photo_screen.dart index 5ea248b..77bb23f 100644 --- a/lib/presentation/pages/add_photo_screen.dart +++ b/lib/presentation/pages/add_photo_screen.dart @@ -25,7 +25,6 @@ class AddPhotoScreen extends StatelessWidget { required this.offer, }); - // متد ساخت توکن Future _generateQrToken(BuildContext context) async { const storage = FlutterSecureStorage(); final userID = await storage.read(key: 'userID'); diff --git a/lib/presentation/pages/comment_page.dart b/lib/presentation/pages/comment_page.dart new file mode 100644 index 0000000..5648030 --- /dev/null +++ b/lib/presentation/pages/comment_page.dart @@ -0,0 +1,273 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_rating_bar/flutter_rating_bar.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:proxibuy/core/config/app_colors.dart'; +import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_bloc.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_event.dart'; +import 'package:proxibuy/presentation/comment/bloc/comment_state.dart'; +import 'package:proxibuy/presentation/pages/offers_page.dart'; + +class CommentPage extends StatefulWidget { + final String discountId; + + const CommentPage({super.key, required this.discountId}); + + @override + State createState() => _CommentPageState(); +} + +class _CommentPageState extends State { + final _commentController = TextEditingController(); + double _rating = 0.0; + final List _images = []; + final ImagePicker _picker = ImagePicker(); + + @override + void dispose() { + _commentController.dispose(); + super.dispose(); + } + + void _pickImage(ImageSource source) async { + if (_images.length >= 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('شما فقط می‌توانید ۲ عکس اضافه کنید.')), + ); + return; + } + final pickedFile = await _picker.pickImage(source: source, imageQuality: 80); + if (pickedFile != null) { + setState(() { + _images.add(File(pickedFile.path)); + }); + } + } + + void _showImageSourceActionSheet() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)), + ), + builder: (context) { + return SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.photo_library, color: AppColors.primary), + title: const Text('انتخاب از گالری'), + onTap: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.gallery); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt, color: AppColors.primary), + title: const Text('گرفتن عکس با دوربین'), + onTap: () { + Navigator.of(context).pop(); + _pickImage(ImageSource.camera); + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CommentBloc(), + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: Image.asset(Assets.images.userinfo.path, fit: BoxFit.cover), + ), + DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.65, + maxChildSize: 0.65, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 50, + height: 5, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(height: 24), + const Text( + 'خریدت با موفقیت انجام شد. منتظر دیدار دوباره‌ات هستیم. لطفا نظرت رو در مورد این تخفیف بهمون بگو.', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Center( + child: RatingBar.builder( + initialRating: 0, + minRating: 1, + direction: Axis.horizontal, + itemCount: 5, + itemPadding: const EdgeInsets.symmetric(horizontal: 15.0), + itemBuilder: (context, _) => Icon( + Icons.star, + color: Colors.amber.shade700, + ), + onRatingUpdate: (rating) => setState(() => _rating = rating), + ), + ), + const SizedBox(height: 24), + TextField( + controller: _commentController, + maxLines: 4, + textAlign: TextAlign.right, + decoration: InputDecoration( + labelText: "گوشمون به شماست", + hintText: "نظراتت رو بگو...", + alignLabelWithHint: true, + suffixIcon: Padding( + padding: const EdgeInsets.all(12.0), + child: IconButton( + icon: SvgPicture.asset( + Assets.icons.galleryAdd.path, + color: _images.length >= 2 ? Colors.grey : AppColors.primary, + ), + onPressed: _images.length >= 2 ? null : _showImageSourceActionSheet, + ), + ), + ), + ), + const SizedBox(height: 16), + if (_images.isNotEmpty) + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _images.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Image.file( + _images[index], + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + ); + }, + ), + ), + const SizedBox(height: 24), + _buildActionButtons(), + ], + ), + ), + ); + }, + ), + ], + ), + ), + ); + } + + Widget _buildActionButtons() { + return BlocConsumer( + listener: (context, state) { + if (state is CommentSubmissionSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('نظر شما با موفقیت ثبت شد. ممنونیم!'), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const OffersPage()), + (route) => false, + ); + } else if (state is CommentSubmissionFailure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.error), backgroundColor: Colors.red), + ); + } + }, + builder: (context, state) { + final isLoading = state is CommentSubmitting; + return Column( + children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: isLoading + ? null + : () { + context.read().add( + SubmitComment( + discountId: widget.discountId, + text: _commentController.text, + score: _rating, + images: _images, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.confirm, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), + ), + child: isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white), + ) + : const Text('ارسال'), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: isLoading + ? null + : () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const OffersPage()), + (route) => false, + ); + }, + child: const Text('رد شدن', style: TextStyle(color: Colors.black)), + ), + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/pages/product_detail_page.dart b/lib/presentation/pages/product_detail_page.dart index 310f2f1..0f17ebe 100644 --- a/lib/presentation/pages/product_detail_page.dart +++ b/lib/presentation/pages/product_detail_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dio/dio.dart'; @@ -11,6 +12,7 @@ import 'package:maps_launcher/maps_launcher.dart'; import 'package:proxibuy/core/config/api_config.dart'; import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/data/models/comment_model.dart'; import 'package:proxibuy/data/models/offer_model.dart'; import 'package:proxibuy/presentation/pages/add_photo_screen.dart'; import 'package:proxibuy/presentation/pages/reservation_details_screen.dart'; @@ -87,20 +89,9 @@ class ProductDetailPage extends StatelessWidget { headers: {'Authorization': 'Bearer $token'}, ); - debugPrint("----------- REQUEST-----------"); - debugPrint("URL: POST $url"); - debugPrint("Headers: ${options.headers}"); - debugPrint("Body: $data"); - debugPrint("-----------------------------"); - final response = await dio.post(url, data: data, options: options); - debugPrint("---------- RESPONSE-----------"); - debugPrint("StatusCode: ${response.statusCode}"); - debugPrint("Data: ${response.data}"); - debugPrint("-----------------------------"); - if (context.mounted) Navigator.of(context).pop(); if (response.statusCode == 200) { @@ -131,11 +122,6 @@ class ProductDetailPage extends StatelessWidget { } on DioException catch (e) { if (context.mounted) Navigator.of(context).pop(); - debugPrint("---------- ERROR-----------"); - debugPrint("StatusCode: ${e.response?.statusCode}"); - debugPrint("Data: ${e.response?.data}"); - debugPrint("--------------------------"); - final errorMessage = e.response?.data?['message'] ?? 'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.'; ScaffoldMessenger.of(context).showSnackBar( @@ -145,10 +131,6 @@ class ProductDetailPage extends StatelessWidget { ); } catch (e) { if (context.mounted) Navigator.of(context).pop(); - debugPrint("---------- GENERAL ERROR -----------"); - debugPrint(e.toString()); - debugPrint("------------------------------------"); - ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(e.toString()), @@ -205,14 +187,56 @@ class _ProductDetailViewState extends State { late List imageList; late String selectedImage; final String _uploadKey = 'upload_image'; + late Future> _commentsFuture; @override void initState() { super.initState(); imageList = List.from(widget.offer.imageUrls)..add(_uploadKey); - selectedImage = imageList.first; + selectedImage = imageList.isNotEmpty ? imageList.first : 'https://via.placeholder.com/400x200.png?text=No+Image'; + _commentsFuture = _fetchComments(); } + Future> _fetchComments() async { + // 1. توکن را از حافظه امن بخوان + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'accessToken'); + + // 2. اگر توکن وجود نداشت، خطا برگردان + // هرچند کاربر لاگین نکرده معمولا به این صفحه دسترسی ندارد + if (token == null) { + throw Exception('Authentication token not found!'); + } + + try { + final dio = Dio(); + // 3. هدر Authorization را به درخواست اضافه کن + final response = await dio.get( + ApiConfig.baseUrl + ApiConfig.getComments + widget.offer.id, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200) { + final List commentsJson = response.data['data']['comments']; + return commentsJson.map((json) => CommentModel.fromJson(json)).toList(); + } else { + // خطاهای دیگر سرور + throw Exception('Failed to load comments with status code: ${response.statusCode}'); + } + } on DioException catch (e) { + // چاپ خطای کامل Dio برای دیباگ بهتر + debugPrint("DioException fetching comments: $e"); + if (e.response != null) { + debugPrint("Response data: ${e.response?.data}"); + } + throw Exception('Failed to load comments: ${e.message}'); + } catch (e) { + debugPrint("Error fetching comments: $e"); + throw Exception('An unknown error occurred: $e'); + } + } + // ############ END: FIX SECTION ############ + void _launchMaps(double lat, double lon, String title) { MapsLauncher.launchCoordinates(lat, lon, title); } @@ -405,8 +429,7 @@ class _ProductDetailViewState extends State { ? widget.offer.expiryTime.difference(DateTime.now()) : Duration.zero; - final formatCurrency = - NumberFormat.decimalPattern('fa_IR'); // Or 'en_US' + final formatCurrency = NumberFormat.decimalPattern('fa_IR'); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0) @@ -687,7 +710,26 @@ class _ProductDetailViewState extends State { const SizedBox(height: 24), _buildDiscountTypeSection(), const SizedBox(height: 24), - CommentsSection(comments: widget.offer.comments), + FutureBuilder>( + future: _commentsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('خطا در بارگذاری نظرات. لطفاً صفحه را رفرش کنید.')); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: Text('هنوز نظری برای این تخفیف ثبت نشده است.'), + ), + ); + } + return CommentsSection(comments: snapshot.data!); + }, + ), ].animate(interval: 80.ms).slideX(begin: -0.05).fadeIn( duration: 400.ms, curve: Curves.easeOut, diff --git a/lib/presentation/pages/reservation_details_screen.dart b/lib/presentation/pages/reservation_details_screen.dart index 0a2ed25..d80cdf7 100644 --- a/lib/presentation/pages/reservation_details_screen.dart +++ b/lib/presentation/pages/reservation_details_screen.dart @@ -1,12 +1,15 @@ import 'dart:async'; - import 'package:audioplayers/audioplayers.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/svg.dart'; import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/core/gen/assets.gen.dart'; import 'package:proxibuy/data/models/offer_model.dart'; +import 'package:proxibuy/presentation/pages/comment_page.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:flutter_animate/flutter_animate.dart'; @@ -30,17 +33,17 @@ class _ReservationConfirmationPageState Timer? _timer; Duration _remaining = Duration.zero; final AudioPlayer _audioPlayer = AudioPlayer(); + StreamSubscription? _mqttSubscription; @override void initState() { super.initState(); - _playSound(); - _calculateRemainingTime(); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { _calculateRemainingTime(); }); + _listenToMqtt(); } void _playSound() async { @@ -69,14 +72,46 @@ class _ReservationConfirmationPageState } } + void _listenToMqtt() async { + final mqttService = context.read(); + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + final discountId = widget.offer.id; + + if (userID == null) { + debugPrint("MQTT Listener: UserID not found, cannot subscribe."); + return; + } + + final topic = 'user-order/$userID/$discountId'; + mqttService.subscribe(topic); + debugPrint("✅ Subscribed to MQTT topic: $topic"); + + _mqttSubscription = mqttService.messages.listen((message) { + debugPrint("✅ MQTT Message received on details page: $message"); + final receivedDiscountId = message['Discount']; + if (receivedDiscountId == discountId) { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => CommentPage(discountId: discountId), + ), + ); + }); + } + } + }); + } + @override void dispose() { _timer?.cancel(); _audioPlayer.dispose(); + _mqttSubscription?.cancel(); super.dispose(); } - @override Widget build(BuildContext context) { return Directionality( @@ -94,13 +129,13 @@ class _ReservationConfirmationPageState crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'تخفیف ${widget.offer.discountType} رزرو شد!', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ) + 'تخفیف ${widget.offer.discountType} رزرو شد!', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ) .animate() .fadeIn(delay: 300.ms, duration: 500.ms) .slideY(begin: -0.2, end: 0), @@ -227,7 +262,7 @@ class _ReservationConfirmationPageState SvgPicture.asset(Assets.icons.ticketDiscount.path), const SizedBox(width: 6), Text( - '(${(100-widget.offer.finalPrice/widget.offer.originalPrice*100).toInt()}%)', + '(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)', style: const TextStyle( fontSize: 16, color: AppColors.singleOfferType, @@ -401,4 +436,4 @@ class _ReservationConfirmationPageState ), ); } -} +} \ No newline at end of file diff --git a/lib/presentation/widgets/reserved_list_item_card.dart b/lib/presentation/widgets/reserved_list_item_card.dart index 30dd7f8..cf2be6f 100644 --- a/lib/presentation/widgets/reserved_list_item_card.dart +++ b/lib/presentation/widgets/reserved_list_item_card.dart @@ -1,11 +1,15 @@ import 'dart:async'; +import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart'; import 'package:proxibuy/core/gen/assets.gen.dart'; +import 'package:proxibuy/presentation/pages/comment_page.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:proxibuy/core/config/app_colors.dart'; import 'package:proxibuy/data/models/offer_model.dart'; @@ -25,6 +29,7 @@ class _ReservedListItemCardState extends State { Timer? _timer; Duration _remaining = Duration.zero; Future? _qrTokenFuture; + StreamSubscription? _mqttSubscription; // برای مدیریت لیسنر MQTT @override void initState() { @@ -57,10 +62,52 @@ class _ReservedListItemCardState extends State { } void _toggleExpansion() { + final isExpired = _remaining <= Duration.zero; + if (isExpired) return; + setState(() { _isExpanded = !_isExpanded; - if (_isExpanded && _qrTokenFuture == null) { - _qrTokenFuture = _generateQrToken(); + if (_isExpanded) { + if (_qrTokenFuture == null) { + _qrTokenFuture = _generateQrToken(); + } + _listenToMqtt(); + } else { + _mqttSubscription?.cancel(); + } + }); + } + + void _listenToMqtt() async { + final mqttService = context.read(); + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + final discountId = widget.offer.id; + + if (userID == null) { + debugPrint("MQTT Listener: UserID not found, cannot subscribe."); + return; + } + + final topic = 'user-order/$userID/$discountId'; + mqttService.subscribe(topic); + debugPrint("✅ Subscribed to MQTT topic: $topic"); + + + _mqttSubscription = mqttService.messages.listen((message) { + debugPrint("✅ MQTT Message received on reserved card: $message"); + final receivedDiscountId = message['Discount']; + + if (receivedDiscountId == discountId) { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => CommentPage(discountId: discountId), + ), + ); + }); + } } }); } @@ -84,6 +131,7 @@ class _ReservedListItemCardState extends State { @override void dispose() { _timer?.cancel(); + _mqttSubscription?.cancel(); super.dispose(); } @@ -104,24 +152,47 @@ class _ReservedListItemCardState extends State { margin: EdgeInsets.zero, child: _buildOfferPrimaryDetails(), ), - _buildActionsRow(), - _buildExpansionPanel(), + _buildActionsRow(isExpired), + if (!isExpired) _buildExpansionPanel(), ], ); - if (isExpired) { - return ColorFiltered( - colorFilter: const ColorFilter.matrix([ - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0, 0, 0, 1, 0, - ]), - child: cardContent, - ); - } - - return cardContent; + return Stack( + children: [ + if (isExpired) + ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, + ]), + child: cardContent, + ) + else + cardContent, + if (isExpired) + Positioned( + top: 12, + left: -35, + child: Transform.rotate( + angle: -45 * (3.1415926535 / 180), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 4), + color: Colors.red, + child: const Text( + 'منقضی شده', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ), + ), + ], + ); } Widget _buildOfferPrimaryDetails() { @@ -195,13 +266,13 @@ class _ReservedListItemCardState extends State { ); } - Widget _buildActionsRow() { + Widget _buildActionsRow(bool isExpired) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (_remaining > Duration.zero) + if (!isExpired) Column( children: [ Localizations.override( @@ -220,7 +291,7 @@ class _ReservedListItemCardState extends State { fontSize: 20, color: AppColors.countdown, ), - decoration: const BoxDecoration(color: Colors.white), + decoration: const BoxDecoration(color: Colors.transparent), shouldShowDays: (d) => d.inDays > 0, shouldShowHours: (d) => d.inHours > 0, shouldShowMinutes: (d) => d.inSeconds > 0, @@ -231,36 +302,30 @@ class _ReservedListItemCardState extends State { ], ) else - const Text( - 'منقضی شده', - style: TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - fontSize: 16, + const SizedBox(height: 0), + SizedBox(width: 10), + if (!isExpired) + TextButton( + onPressed: _toggleExpansion, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _isExpanded ? 'بستن' : 'اطلاعات بیشتر', + style: TextStyle(color: AppColors.active), + ), + const SizedBox(width: 12), + AnimatedRotation( + turns: _isExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 300), + child: SvgPicture.asset( + Assets.icons.arrowDown.path, + height: 20, + ), + ), + ], ), ), - SizedBox(width: 10), - TextButton( - onPressed: _toggleExpansion, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _isExpanded ? 'بستن' : 'اطلاعات بیشتر', - style: TextStyle(color: AppColors.active), - ), - const SizedBox(width: 12), - AnimatedRotation( - turns: _isExpanded ? 0.5 : 0, - duration: const Duration(milliseconds: 300), - child: SvgPicture.asset( - Assets.icons.arrowDown.path, - height: 20, - ), - ), - ], - ), - ), ], ), ); @@ -324,6 +389,7 @@ class _ReservedListItemCardState extends State { Widget _buildExpansionPanel() { final formatCurrency = NumberFormat.decimalPattern('fa_IR'); + final isExpired = _remaining <= Duration.zero; return AnimatedCrossFade( firstChild: Container(), @@ -395,39 +461,41 @@ class _ReservedListItemCardState extends State { ), ], ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Color.fromARGB(255, 246, 246, 246), - borderRadius: BorderRadius.circular(16), + if (!isExpired) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Color.fromARGB(255, 246, 246, 246), + borderRadius: BorderRadius.circular(16), + ), + child: FutureBuilder( + future: _qrTokenFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 280.0, + child: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError) { + return const SizedBox( + height: 280.0, + child: Center(child: Text("خطا در ساخت کد QR")), + ); + } + if (snapshot.hasData) { + return QrImageView( + data: snapshot.data!, + version: QrVersions.auto, + size: 280.0, + ); + } + return const SizedBox.shrink(); + }, + ), ), - child: FutureBuilder( - future: _qrTokenFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const SizedBox( - height: 280.0, - child: Center(child: CircularProgressIndicator()), - ); - } - if (snapshot.hasError) { - return const SizedBox( - height: 280.0, - child: Center(child: Text("خطا در ساخت کد QR")), - ); - } - if (snapshot.hasData) { - return QrImageView( - data: snapshot.data!, - version: QrVersions.auto, - size: 280.0, - ); - } - return const SizedBox.shrink(); - }, - ), - ), + ], ], ), ), diff --git a/lib/presentation/widgets/user_comment_card.dart b/lib/presentation/widgets/user_comment_card.dart index f33a59b..d0a54ff 100644 --- a/lib/presentation/widgets/user_comment_card.dart +++ b/lib/presentation/widgets/user_comment_card.dart @@ -31,7 +31,7 @@ class CustomStarRating extends StatelessWidget { stars.add(_buildStar(Assets.icons.star2.path)); remaining = 0; } else { - stars.add(_buildStar(Assets.icons.starHalf.path,)); + stars.add(_buildStar(Assets.icons.star2.path,)); } } return Row(mainAxisSize: MainAxisSize.min, children: stars); diff --git a/pubspec.lock b/pubspec.lock index 9f7b7ff..85ae763 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -739,6 +739,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.28" + flutter_rating_bar: + dependency: "direct main" + description: + name: flutter_rating_bar + sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93 + url: "https://pub.dev" + source: hosted + version: "4.0.1" flutter_secure_storage: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0f8ac88..00ae85f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: workmanager: ^0.7.0 firebase_messaging: ^15.2.10 firebase_crashlytics: ^4.3.10 + flutter_rating_bar: ^4.0.1 dev_dependencies: flutter_test: -- 2.40.1 From 60c91e8fbb0cf352c3efa88557909562fb87c25f Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Mon, 4 Aug 2025 14:44:50 +0330 Subject: [PATCH 4/6] fix category and change comment ui --- .../bloc/notification_preferences_bloc.dart | 12 +- lib/presentation/pages/comment_page.dart | 417 ++++++++++++------ 2 files changed, 285 insertions(+), 144 deletions(-) diff --git a/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart index deff79c..af19983 100644 --- a/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart +++ b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart @@ -1,3 +1,5 @@ +// lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart + import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -26,12 +28,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 +128,4 @@ class NotificationPreferencesBloc ResetSubmissionStatus event, Emitter emit) { emit(state.copyWith(submissionSuccess: false)); } -} - - +} \ No newline at end of file diff --git a/lib/presentation/pages/comment_page.dart b/lib/presentation/pages/comment_page.dart index 5648030..53a0c24 100644 --- a/lib/presentation/pages/comment_page.dart +++ b/lib/presentation/pages/comment_page.dart @@ -10,6 +10,7 @@ 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; @@ -39,7 +40,10 @@ class _CommentPageState extends State { ); return; } - final pickedFile = await _picker.pickImage(source: source, imageQuality: 80); + final pickedFile = await _picker.pickImage( + source: source, + imageQuality: 80, + ); if (pickedFile != null) { setState(() { _images.add(File(pickedFile.path)); @@ -47,6 +51,12 @@ class _CommentPageState extends State { } } + void _removeImage(int index) { + setState(() { + _images.removeAt(index); + }); + } + void _showImageSourceActionSheet() { showModalBottomSheet( context: context, @@ -58,7 +68,10 @@ class _CommentPageState extends State { child: Wrap( children: [ ListTile( - leading: const Icon(Icons.photo_library, color: AppColors.primary), + leading: const Icon( + Icons.photo_library, + color: AppColors.primary, + ), title: const Text('انتخاب از گالری'), onTap: () { Navigator.of(context).pop(); @@ -85,113 +98,126 @@ class _CommentPageState extends State { return BlocProvider( create: (context) => CommentBloc(), child: Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: Image.asset(Assets.images.userinfo.path, fit: BoxFit.cover), + 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), ), - 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: 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, ), - 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(), - ], - ), - ), - ); - }, + ), + 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), ), ], ), @@ -199,6 +225,108 @@ class _CommentPageState extends State { ); } + 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) { @@ -207,6 +335,7 @@ class _CommentPageState extends State { const SnackBar( content: Text('نظر شما با موفقیت ثبت شد. ممنونیم!'), backgroundColor: Colors.green, + behavior: SnackBarBehavior.floating, ), ); Navigator.of(context).pushAndRemoveUntil( @@ -215,7 +344,11 @@ class _CommentPageState extends State { ); } else if (state is CommentSubmissionFailure) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(state.error), backgroundColor: Colors.red), + SnackBar( + content: Text(state.error), + backgroundColor: Colors.red, + behavior: SnackBarBehavior.floating, + ), ); } }, @@ -226,48 +359,56 @@ class _CommentPageState extends State { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: isLoading - ? null - : () { - context.read().add( - SubmitComment( - discountId: widget.discountId, - text: _commentController.text, - score: _rating, - images: _images, - ), - ); - }, + 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)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), ), - child: isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(color: Colors.white), - ) - : const Text('ارسال'), + 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)), + onPressed: + isLoading + ? null + : () { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const OffersPage()), + (route) => false, + ); + }, + child: const Text( + 'رد شدن', + style: TextStyle(color: Colors.black), + ), ), ], ); }, ); } -} \ No newline at end of file +} -- 2.40.1 From 570ff6bb063202dff2af0f3dcebaa48af17b5702 Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Thu, 7 Aug 2025 11:37:15 +0330 Subject: [PATCH 5/6] added notif offer page --- lib/core/config/app_colors.dart | 2 + lib/data/models/notification_model.dart | 37 ++ .../bloc/notification_preferences_bloc.dart | 2 - .../pages/notification_preferences_page.dart | 1 - lib/presentation/pages/offers_page.dart | 507 +++++++++++------- .../pages/product_detail_page.dart | 29 +- .../widgets/notification_panel.dart | 253 +++++++++ lib/services/mqtt_service.dart | 5 +- pubspec.lock | 16 + pubspec.yaml | 2 + 10 files changed, 619 insertions(+), 235 deletions(-) create mode 100644 lib/data/models/notification_model.dart create mode 100644 lib/presentation/widgets/notification_panel.dart 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/notification_model.dart b/lib/data/models/notification_model.dart new file mode 100644 index 0000000..109be51 --- /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.parse(json['createdAt'] ?? DateTime.now().toIso8601String()), + 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/presentation/notification_preferences/bloc/notification_preferences_bloc.dart b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart index af19983..679ff79 100644 --- a/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart +++ b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart @@ -1,5 +1,3 @@ -// lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart - import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; diff --git a/lib/presentation/pages/notification_preferences_page.dart b/lib/presentation/pages/notification_preferences_page.dart index 0e27f2c..cce381d 100644 --- a/lib/presentation/pages/notification_preferences_page.dart +++ b/lib/presentation/pages/notification_preferences_page.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; diff --git a/lib/presentation/pages/offers_page.dart b/lib/presentation/pages/offers_page.dart index 0ba60b7..bd8ba99 100644 --- a/lib/presentation/pages/offers_page.dart +++ b/lib/presentation/pages/offers_page.dart @@ -1,5 +1,8 @@ +// lib/presentation/pages/offers_page.dart import 'dart:async'; +import 'dart:math' as math; // برای محاسبات ریاضی +import 'package:animations/animations.dart'; import 'package:collection/collection.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; @@ -24,7 +27,9 @@ import 'package:proxibuy/presentation/pages/notification_preferences_page.dart'; 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_panel.dart'; import 'package:proxibuy/presentation/widgets/notification_permission_dialog.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class OffersPage extends StatefulWidget { @@ -36,35 +41,99 @@ class OffersPage extends StatefulWidget { State createState() => _OffersPageState(); } -class _OffersPageState extends State { +class _OffersPageState extends State with SingleTickerProviderStateMixin { List _selectedCategories = []; StreamSubscription? _locationServiceSubscription; StreamSubscription? _connectivitySubscription; bool _isGpsEnabled = false; bool _isConnectedToInternet = true; + bool _isSearchingRandomly = false; + + bool _showNotificationPanel = false; + final GlobalKey _notificationIconKey = GlobalKey(); + late AnimationController _animationController; + late Animation _animation; + int _notificationCount = 0; @override void initState() { super.initState(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _checkInitialConnectivity(); _initializePage(); _initConnectivityListener(); _fetchInitialReservations(); + _fetchNotificationCount(); } @override void dispose() { _locationServiceSubscription?.cancel(); _connectivitySubscription?.cancel(); + _animationController.dispose(); super.dispose(); } + void _toggleNotificationPanel() { + setState(() { + _showNotificationPanel = !_showNotificationPanel; + if (_showNotificationPanel) { + _animationController.forward(); + } else { + _animationController.reverse(); + _fetchNotificationCount(); + } + }); + } + + Future _handleRandomSearch() async { + setState(() => _isSearchingRandomly = true); + + const storage = FlutterSecureStorage(); + final mqttService = context.read(); + final userID = await storage.read(key: 'userID'); + + if (!mounted) return; + + if (userID == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('برای این کار باید وارد حساب کاربری خود شوید.')), + ); + setState(() => _isSearchingRandomly = false); + return; + } + + if (!mqttService.isConnected) { + final token = await storage.read(key: 'accessToken'); + if (token != null) { + await mqttService.connect(token); + } else { + setState(() => _isSearchingRandomly = false); + return; + } + } + + final randomTopic = 'user-proxybuy-random/$userID'; + mqttService.subscribe(randomTopic); + mqttService.publish('proxybuy-random', {'userID': userID}); + + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _isSearchingRandomly = false); + } + }); + } + Future _checkInitialConnectivity() async { final connectivityResult = await Connectivity().checkConnectivity(); if (!mounted) return; setState(() { - _isConnectedToInternet = - !connectivityResult.contains(ConnectivityResult.none); + _isConnectedToInternet = !connectivityResult.contains(ConnectivityResult.none); }); } @@ -85,7 +154,6 @@ class _OffersPageState extends State { void _listenToBackgroundService() { FlutterBackgroundService().on('update').listen((event) { if (event == null || event['offers'] == null) return; - final data = event['offers']['data']; if (data == null || data is! List) { if (mounted) { @@ -93,13 +161,8 @@ class _OffersPageState extends State { } return; } - try { - List offers = data - .whereType>() - .map((json) => OfferModel.fromJson(json)) - .toList(); - + List offers = data.whereType>().map((json) => OfferModel.fromJson(json)).toList(); if (mounted) { context.read().add(OffersReceivedFromMqtt(offers)); } @@ -111,29 +174,17 @@ class _OffersPageState extends State { } void _initConnectivityListener() { - _connectivitySubscription = - Connectivity().onConnectivityChanged.listen((results) { + _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { final hasConnection = !results.contains(ConnectivityResult.none); if (mounted && _isConnectedToInternet != hasConnection) { - setState(() { - _isConnectedToInternet = hasConnection; - }); - + setState(() => _isConnectedToInternet = hasConnection); if (hasConnection) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('اتصال به اینترنت برقرار شد.'), - backgroundColor: Colors.green, - ), - ); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت برقرار شد.'), backgroundColor: Colors.green)); + _fetchNotificationCount(); } else { context.read().add(ClearOffers()); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('اتصال به اینترنت قطع شد.'), - backgroundColor: Colors.red, - ), - ); + setState(() => _notificationCount = 0); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت قطع شد.'), backgroundColor: Colors.red)); } } }); @@ -145,21 +196,11 @@ class _OffersPageState extends State { const storage = FlutterSecureStorage(); final token = await storage.read(key: 'accessToken'); if (token == null) return; - final dio = Dio(); - final response = await dio.get( - ApiConfig.baseUrl + ApiConfig.getReservations, - options: Options(headers: {'Authorization': 'Bearer $token'}), - ); - + final response = await dio.get(ApiConfig.baseUrl + ApiConfig.getReservations, options: Options(headers: {'Authorization': 'Bearer $token'})); if (response.statusCode == 200 && mounted) { final List reserves = response.data['reserves']; - final List reservedIds = reserves - .map((reserveData) => - (reserveData['Discount']['ID'] as String?) ?? '') - .where((id) => id.isNotEmpty) - .toList(); - + final List reservedIds = reserves.map((reserveData) => (reserveData['Discount']['ID'] as String?) ?? '').where((id) => id.isNotEmpty).toList(); context.read().setReservedIds(reservedIds); } } catch (e) { @@ -167,73 +208,69 @@ class _OffersPageState extends State { } } + Future _fetchNotificationCount() async { + if (!_isConnectedToInternet) return; + try { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'accessToken'); + if (token == null) return; + + final dio = Dio(); + final response = await dio.get( + '${ApiConfig.baseUrl}/notify/get', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && mounted) { + final List data = response.data['data']; + setState(() { + _notificationCount = data.length; + }); + } + } catch (e) { + debugPrint("Error fetching notification count: $e"); + } + } + void _initLocationListener() { _checkInitialGpsStatus(); - _locationServiceSubscription = - Geolocator.getServiceStatusStream().listen((status) { + _locationServiceSubscription = Geolocator.getServiceStatusStream().listen((status) { final isEnabled = status == ServiceStatus.enabled; if (mounted && _isGpsEnabled != isEnabled) { - setState(() { - _isGpsEnabled = isEnabled; - }); - if (!isEnabled) { - context.read().add(ClearOffers()); - } + setState(() => _isGpsEnabled = isEnabled); + if (!isEnabled) context.read().add(ClearOffers()); } }); } Future _checkInitialGpsStatus() async { final status = await Geolocator.isLocationServiceEnabled(); - if (mounted) { - setState(() { - _isGpsEnabled = status; - }); - } + if (mounted) setState(() => _isGpsEnabled = status); } Future _loadPreferences() async { final prefs = await SharedPreferences.getInstance(); - final savedCategories = - prefs.getStringList('user_selected_categories') ?? []; - - if (mounted) { - setState(() { - _selectedCategories = savedCategories; - }); - } + final savedCategories = prefs.getStringList('user_selected_categories') ?? []; + if (mounted) setState(() => _selectedCategories = savedCategories); } Future _handleRefresh() { final completer = Completer(); final service = FlutterBackgroundService(); - final timeout = Timer(const Duration(seconds: 20), () { if (!completer.isCompleted) { completer.completeError('Request timed out.'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Request timed out. Please try again.')), - ); - } + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Request timed out. Please try again.'))); } }); - - final StreamSubscription?> subscription = - service.on('update').listen((event) { - if (!completer.isCompleted) { - completer.complete(); - } + final StreamSubscription?> subscription = service.on('update').listen((event) { + if (!completer.isCompleted) completer.complete(); }); - completer.future.whenComplete(() { subscription.cancel(); timeout.cancel(); }); - service.invoke('force_refresh'); - return completer.future; } @@ -246,40 +283,20 @@ class _OffersPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'دسته‌بندی‌های مورد علاقه شما', - style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold), - ), + const Text('دسته‌بندی‌های مورد علاقه شما', style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold)), TextButton( onPressed: () async { - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const NotificationPreferencesPage( - loadFavoritesOnStart: true, - ), - ), - ); - + final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const NotificationPreferencesPage(loadFavoritesOnStart: true))); if (!mounted) return; - - context - .read() - .add(ResetSubmissionStatus()); - + context.read().add(ResetSubmissionStatus()); await _loadPreferences(); - - if (result == true) { - _handleRefresh(); - } + if (result == true) _handleRefresh(); }, child: Row( children: [ SvgPicture.asset(Assets.icons.edit.path), const SizedBox(width: 4), - const Text( - 'ویرایش', - style: TextStyle(color: AppColors.active), - ), + const Text('ویرایش', style: TextStyle(color: AppColors.active)), ], ), ), @@ -290,10 +307,7 @@ class _OffersPageState extends State { if (_selectedCategories.isEmpty) const Padding( padding: EdgeInsets.only(bottom: 8.0), - child: Text( - 'شما هنوز دسته‌بندی مورد علاقه خود را انتخاب نکرده‌اید.', - style: TextStyle(color: Colors.grey), - ), + child: Text('شما هنوز دسته‌بندی مورد علاقه خود را انتخاب نکرده‌اید.', style: TextStyle(color: Colors.grey)), ) else Wrap( @@ -301,14 +315,8 @@ class _OffersPageState extends State { runSpacing: 8.0, children: _selectedCategories.map((category) { return Container( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 6.0, - ), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(20.0), - ), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(20.0)), child: Text(category), ); }).toList(), @@ -327,15 +335,37 @@ class _OffersPageState extends State { backgroundColor: Colors.white, automaticallyImplyLeading: false, title: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 15.0, - vertical: 0.0, - ), + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 0.0), child: Assets.icons.logoWithName.svg(height: 40, width: 200), ), actions: [ - IconButton( - onPressed: () {}, icon: Assets.icons.notification.svg()), + Stack( + alignment: Alignment.center, + children: [ + IconButton( + key: _notificationIconKey, + onPressed: _toggleNotificationPanel, + icon: Assets.icons.notification.svg(), + ), + if (_notificationCount > 0) + Positioned( + top: 0, + right: 2, + child: GestureDetector( + onTap: _toggleNotificationPanel, + 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: Padding( + padding: const EdgeInsets.fromLTRB(2, 4, 2, 2), + 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; @@ -343,13 +373,11 @@ class _OffersPageState extends State { alignment: Alignment.center, children: [ IconButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ReservedListPage(), - ), - ); - }, + onPressed: () => Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => const ReservedListPage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + SharedAxisTransition(animation: animation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: child), + )), icon: Assets.icons.scanBarcode.svg(), ), if (reservedCount > 0) @@ -357,36 +385,18 @@ class _OffersPageState extends State { top: 0, right: 2, child: GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ReservedListPage(), - ), - ); - }, + onTap: () => Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => const ReservedListPage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + SharedAxisTransition(animation: animation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: child), + )), child: Container( padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: - Border.all(color: Colors.white, width: 1.5), - ), - constraints: const BoxConstraints( - minWidth: 18, - minHeight: 18, - ), + decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 1.5)), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), child: Padding( padding: const EdgeInsets.fromLTRB(2, 4, 2, 2), - child: Text( - '$reservedCount', - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + child: Text('$reservedCount', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), textAlign: TextAlign.center), ), ), ), @@ -398,38 +408,115 @@ class _OffersPageState extends State { const SizedBox(width: 8), ], ), - body: RefreshIndicator( - onRefresh: _handleRefresh, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFavoriteCategoriesSection(), - OffersView( - isGpsEnabled: _isGpsEnabled, - isConnectedToInternet: _isConnectedToInternet, - selectedCategories: _selectedCategories, + body: Stack( + children: [ + RefreshIndicator( + onRefresh: _handleRefresh, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFavoriteCategoriesSection(), + OffersView( + isGpsEnabled: _isGpsEnabled, + isConnectedToInternet: _isConnectedToInternet, + selectedCategories: _selectedCategories, + onRandomSearch: _handleRandomSearch, + isSearchingRandomly: _isSearchingRandomly, + ), + ], ), - ], + ), ), - ), + _buildNotificationOverlay(), + ], ), ), ); } + + Widget _buildNotificationOverlay() { + if (!_showNotificationPanel && _animationController.isDismissed) { + return const SizedBox.shrink(); + } + + final RenderBox? renderBox = _notificationIconKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return const SizedBox.shrink(); + + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + final iconCenter = Offset(position.dx + size.width / 2, position.dy + size.height / 2); + + final screenHeight = MediaQuery.of(context).size.height; + final screenWidth = MediaQuery.of(context).size.width; + final maxRadius = math.sqrt(math.pow(screenWidth, 2) + math.pow(screenHeight, 2)); + + _animation = Tween(begin: 0.0, end: maxRadius).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInCubic), + ); + + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Stack( + children: [ + if (_showNotificationPanel || !_animationController.isDismissed) + Positioned.fill( + child: GestureDetector( + onTap: _toggleNotificationPanel, + child: Container( + color: Colors.black.withOpacity(0.4 * _animationController.value), + ), + ), + ), + ClipPath( + clipper: CircularRevealClipper( + radius: _animation.value, + center: iconCenter, + ), + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(top: kToolbarHeight - 40, left: 16, right: 16), + child: Material( + elevation: 12.0, + borderRadius: BorderRadius.circular(16), + child: Container( + height: MediaQuery.of(context).size.height * 0.40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: NotificationPanel( + onClose: _toggleNotificationPanel, + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } } class OffersView extends StatelessWidget { final bool isGpsEnabled; final bool isConnectedToInternet; final List selectedCategories; + final VoidCallback onRandomSearch; + final bool isSearchingRandomly; const OffersView({ super.key, required this.isGpsEnabled, required this.isConnectedToInternet, required this.selectedCategories, + required this.onRandomSearch, + required this.isSearchingRandomly, }); @override @@ -463,9 +550,7 @@ class OffersView extends StatelessWidget { if (state is OffersLoadSuccess) { final filteredOffers = selectedCategories.isEmpty ? state.offers - : state.offers - .where((offer) => selectedCategories.contains(offer.category)) - .toList(); + : state.offers.where((offer) => selectedCategories.contains(offer.category)).toList(); if (filteredOffers.isEmpty) { return const SizedBox( @@ -482,10 +567,7 @@ class OffersView extends StatelessWidget { ); } - final groupedOffers = groupBy( - filteredOffers, - (OfferModel offer) => offer.category, - ); + final groupedOffers = groupBy(filteredOffers, (OfferModel offer) => offer.category); final categories = groupedOffers.keys.toList(); return ListView.builder( @@ -496,12 +578,10 @@ class OffersView extends StatelessWidget { itemBuilder: (context, index) { final category = categories[index]; final offersForCategory = groupedOffers[category]!; - - return CategoryOffersRow( - categoryTitle: category, - offers: offersForCategory, - ).animate().fade(duration: 500.ms).slideY( - begin: 0.3, duration: 400.ms, curve: Curves.easeOut); + return CategoryOffersRow(categoryTitle: category, offers: offersForCategory) + .animate() + .fade(duration: 500.ms) + .slideY(begin: 0.3, duration: 400.ms, curve: Curves.easeOut); }, ); } @@ -536,25 +616,33 @@ class OffersView extends StatelessWidget { backgroundColor: AppColors.confirm, foregroundColor: Colors.white, disabledBackgroundColor: Colors.grey, - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 125, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(50), - ), - ), - child: const Text( - 'فعال‌سازی GPS', - style: TextStyle( - fontFamily: 'Dana', - fontSize: 16, - fontWeight: FontWeight.normal, - ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 125), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), ), + child: const Text('فعال‌سازی GPS', style: TextStyle(fontFamily: 'Dana', fontSize: 16, fontWeight: FontWeight.normal)), ), const SizedBox(height: 15), - const Text('جست‌وجوی تصادفی'), + InkWell( + onTap: isSearchingRandomly ? null : onRandomSearch, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: isSearchingRandomly + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('در حال جستجو...'), + SizedBox(width: 8), + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ) + : const Text('جست‌وجوی تصادفی'), + ), + ), ], ), ), @@ -571,18 +659,29 @@ class OffersView extends StatelessWidget { children: [ Icon(Icons.wifi_off_rounded, size: 80, color: Colors.grey[400]), const SizedBox(height: 20), - const Text( - "اتصال به اینترنت برقرار نیست", - style: TextStyle(fontSize: 18, color: Colors.grey), - ), + const Text("اتصال به اینترنت برقرار نیست", style: TextStyle(fontSize: 18, color: Colors.grey)), const SizedBox(height: 10), - const Text( - "لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.", - style: TextStyle(color: Colors.grey), - ), + const Text("لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.", style: TextStyle(color: Colors.grey)), ], ), ), ); } +} + +class CircularRevealClipper extends CustomClipper { + final double radius; + final Offset center; + + CircularRevealClipper({required this.radius, required this.center}); + + @override + Path getClip(Size size) { + return Path()..addOval(Rect.fromCircle(center: center, radius: radius)); + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) { + return true; + } } \ No newline at end of file diff --git a/lib/presentation/pages/product_detail_page.dart b/lib/presentation/pages/product_detail_page.dart index 0f17ebe..ce5eda5 100644 --- a/lib/presentation/pages/product_detail_page.dart +++ b/lib/presentation/pages/product_detail_page.dart @@ -198,19 +198,15 @@ class _ProductDetailViewState extends State { } Future> _fetchComments() async { - // 1. توکن را از حافظه امن بخوان const storage = FlutterSecureStorage(); final token = await storage.read(key: 'accessToken'); - // 2. اگر توکن وجود نداشت، خطا برگردان - // هرچند کاربر لاگین نکرده معمولا به این صفحه دسترسی ندارد if (token == null) { throw Exception('Authentication token not found!'); } try { final dio = Dio(); - // 3. هدر Authorization را به درخواست اضافه کن final response = await dio.get( ApiConfig.baseUrl + ApiConfig.getComments + widget.offer.id, options: Options(headers: {'Authorization': 'Bearer $token'}), @@ -220,11 +216,9 @@ class _ProductDetailViewState extends State { final List commentsJson = response.data['data']['comments']; return commentsJson.map((json) => CommentModel.fromJson(json)).toList(); } else { - // خطاهای دیگر سرور throw Exception('Failed to load comments with status code: ${response.statusCode}'); } } on DioException catch (e) { - // چاپ خطای کامل Dio برای دیباگ بهتر debugPrint("DioException fetching comments: $e"); if (e.response != null) { debugPrint("Response data: ${e.response?.data}"); @@ -235,7 +229,6 @@ class _ProductDetailViewState extends State { throw Exception('An unknown error occurred: $e'); } } - // ############ END: FIX SECTION ############ void _launchMaps(double lat, double lon, String title) { MapsLauncher.launchCoordinates(lat, lon, title); @@ -408,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), - ), - ), ); } @@ -686,7 +666,7 @@ class _ProductDetailViewState extends State { slideDirection: SlideDirection.up, separator: ':', style: const TextStyle( - fontSize: 50, + fontSize: 45, fontWeight: FontWeight.bold, color: AppColors.countdown, ), @@ -720,12 +700,7 @@ class _ProductDetailViewState extends State { return Center(child: Text('خطا در بارگذاری نظرات. لطفاً صفحه را رفرش کنید.')); } if (!snapshot.hasData || snapshot.data!.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), - child: Text('هنوز نظری برای این تخفیف ثبت نشده است.'), - ), - ); + return SizedBox(); } return CommentsSection(comments: snapshot.data!); }, diff --git a/lib/presentation/widgets/notification_panel.dart b/lib/presentation/widgets/notification_panel.dart new file mode 100644 index 0000000..00db1c2 --- /dev/null +++ b/lib/presentation/widgets/notification_panel.dart @@ -0,0 +1,253 @@ +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/api_config.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; + + const NotificationPanel({super.key, required this.onClose}); + + @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 = 'برای مشاهده اعلان‌ها، لطفا ابتدا وارد شوید.'; + }); + } + return; + } + + try { + final response = await _dio.get( + '${ApiConfig.baseUrl}/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; + }); + } else { + setState(() { + _isLoading = false; + _errorMessage = 'خطا در دریافت اطلاعات.'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'اتصال به سرور برقرار نشد.'; + }); + } + } + } + + 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( + '${ApiConfig.baseUrl}/notify/ignore/$notificationId', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && mounted) { + setState(() { + _notifications.removeWhere((n) => n.id == notificationId); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response.data['message'] ?? 'خطا در حذف اعلان.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('خطا در ارتباط با سرور.')), + ); + print(e.toString()); + } + } + } + + 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/services/mqtt_service.dart b/lib/services/mqtt_service.dart index 8cac638..2f62af8 100644 --- a/lib/services/mqtt_service.dart +++ b/lib/services/mqtt_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; 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'; @@ -23,7 +24,9 @@ class MqttService { } Future connect(String token) async { - final String clientId = 'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0'); + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + final String clientId = userID ?? 'proxibuy'; final String username = 'ignored'; final String password = token; diff --git a/pubspec.lock b/pubspec.lock index 85ae763..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: @@ -1445,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 diff --git a/pubspec.yaml b/pubspec.yaml index 00ae85f..8678823 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,8 @@ dependencies: 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: -- 2.40.1 From e4fc94a3e756943da9ab1ef113e97f6e6799a039 Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Sat, 9 Aug 2025 13:55:20 +0330 Subject: [PATCH 6/6] fixed mqtt bugs --- lib/data/models/notification_model.dart | 2 +- lib/presentation/pages/offers_page.dart | 778 ++++++++++-------- .../widgets/notification_panel.dart | 39 +- lib/services/mqtt_service.dart | 99 ++- 4 files changed, 541 insertions(+), 377 deletions(-) diff --git a/lib/data/models/notification_model.dart b/lib/data/models/notification_model.dart index 109be51..107eca9 100644 --- a/lib/data/models/notification_model.dart +++ b/lib/data/models/notification_model.dart @@ -27,7 +27,7 @@ class NotificationModel { return NotificationModel( id: json['ID'] ?? '', description: json['Description'] ?? 'No description available.', - createdAt: DateTime.parse(json['createdAt'] ?? DateTime.now().toIso8601String()), + createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(), discountId: json['Discount']?['ID'] ?? '', discountName: json['Discount']?['Name'] ?? 'Unknown Discount', shopName: json['Discount']?['Shop']?['Name'] ?? 'Unknown Shop', diff --git a/lib/presentation/pages/offers_page.dart b/lib/presentation/pages/offers_page.dart index bd8ba99..233926c 100644 --- a/lib/presentation/pages/offers_page.dart +++ b/lib/presentation/pages/offers_page.dart @@ -1,15 +1,10 @@ -// lib/presentation/pages/offers_page.dart - import 'dart:async'; -import 'dart:math' as math; // برای محاسبات ریاضی -import 'package:animations/animations.dart'; import 'package:collection/collection.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/svg.dart'; import 'package:geolocator/geolocator.dart'; @@ -27,8 +22,8 @@ import 'package:proxibuy/presentation/pages/notification_preferences_page.dart'; 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_panel.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'; @@ -41,29 +36,24 @@ class OffersPage extends StatefulWidget { State createState() => _OffersPageState(); } -class _OffersPageState extends State with SingleTickerProviderStateMixin { +class _OffersPageState extends State { List _selectedCategories = []; StreamSubscription? _locationServiceSubscription; + StreamSubscription? _mqttMessageSubscription; StreamSubscription? _connectivitySubscription; + Timer? _locationTimer; + bool _isSubscribedToOffers = false; bool _isGpsEnabled = false; bool _isConnectedToInternet = true; - bool _isSearchingRandomly = false; - - bool _showNotificationPanel = false; - final GlobalKey _notificationIconKey = GlobalKey(); - late AnimationController _animationController; - late Animation _animation; + // Notifications panel state + final GlobalKey _bellKey = GlobalKey(); + OverlayEntry? _notifOverlay; + bool _notifVisible = false; int _notificationCount = 0; @override void initState() { super.initState(); - - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 400), - ); - _checkInitialConnectivity(); _initializePage(); _initConnectivityListener(); @@ -74,61 +64,13 @@ class _OffersPageState extends State with SingleTickerProviderStateM @override void dispose() { _locationServiceSubscription?.cancel(); + _mqttMessageSubscription?.cancel(); + _locationTimer?.cancel(); _connectivitySubscription?.cancel(); - _animationController.dispose(); + _removeNotificationOverlay(); super.dispose(); } - void _toggleNotificationPanel() { - setState(() { - _showNotificationPanel = !_showNotificationPanel; - if (_showNotificationPanel) { - _animationController.forward(); - } else { - _animationController.reverse(); - _fetchNotificationCount(); - } - }); - } - - Future _handleRandomSearch() async { - setState(() => _isSearchingRandomly = true); - - const storage = FlutterSecureStorage(); - final mqttService = context.read(); - final userID = await storage.read(key: 'userID'); - - if (!mounted) return; - - if (userID == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('برای این کار باید وارد حساب کاربری خود شوید.')), - ); - setState(() => _isSearchingRandomly = false); - return; - } - - if (!mqttService.isConnected) { - final token = await storage.read(key: 'accessToken'); - if (token != null) { - await mqttService.connect(token); - } else { - setState(() => _isSearchingRandomly = false); - return; - } - } - - final randomTopic = 'user-proxybuy-random/$userID'; - mqttService.subscribe(randomTopic); - mqttService.publish('proxybuy-random', {'userID': userID}); - - Future.delayed(const Duration(seconds: 2), () { - if (mounted) { - setState(() => _isSearchingRandomly = false); - } - }); - } - Future _checkInitialConnectivity() async { final connectivityResult = await Connectivity().checkConnectivity(); if (!mounted) return; @@ -146,45 +88,36 @@ class _OffersPageState extends State with SingleTickerProviderStateM } }); } + await _loadPreferences(); _initLocationListener(); - _listenToBackgroundService(); - } - - void _listenToBackgroundService() { - FlutterBackgroundService().on('update').listen((event) { - if (event == null || event['offers'] == null) return; - final data = event['offers']['data']; - if (data == null || data is! List) { - if (mounted) { - context.read().add(const OffersReceivedFromMqtt([])); - } - return; - } - try { - List offers = data.whereType>().map((json) => OfferModel.fromJson(json)).toList(); - if (mounted) { - context.read().add(OffersReceivedFromMqtt(offers)); - } - } catch (e, stackTrace) { - debugPrint("❌ Error parsing offers from Background Service: $e"); - debugPrint(stackTrace.toString()); - } - }); + _subscribeToUserOffersOnLoad(); } void _initConnectivityListener() { - _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { + _connectivitySubscription = + Connectivity().onConnectivityChanged.listen((results) { final hasConnection = !results.contains(ConnectivityResult.none); if (mounted && _isConnectedToInternet != hasConnection) { - setState(() => _isConnectedToInternet = hasConnection); + setState(() { + _isConnectedToInternet = hasConnection; + }); + if (hasConnection) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت برقرار شد.'), backgroundColor: Colors.green)); - _fetchNotificationCount(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('اتصال به اینترنت برقرار شد.'), + backgroundColor: Colors.green, + ), + ); } else { context.read().add(ClearOffers()); - setState(() => _notificationCount = 0); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت قطع شد.'), backgroundColor: Colors.red)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('اتصال به اینترنت قطع شد.'), + backgroundColor: Colors.red, + ), + ); } } }); @@ -196,11 +129,21 @@ class _OffersPageState extends State with SingleTickerProviderStateM const storage = FlutterSecureStorage(); final token = await storage.read(key: 'accessToken'); if (token == null) return; + final dio = Dio(); - final response = await dio.get(ApiConfig.baseUrl + ApiConfig.getReservations, options: Options(headers: {'Authorization': 'Bearer $token'})); + final response = await dio.get( + ApiConfig.baseUrl + ApiConfig.getReservations, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + if (response.statusCode == 200 && mounted) { final List reserves = response.data['reserves']; - final List reservedIds = reserves.map((reserveData) => (reserveData['Discount']['ID'] as String?) ?? '').where((id) => id.isNotEmpty).toList(); + final List reservedIds = reserves + .map((reserveData) => + (reserveData['Discount']['ID'] as String?) ?? '') + .where((id) => id.isNotEmpty) + .toList(); + context.read().setReservedIds(reservedIds); } } catch (e) { @@ -208,70 +151,282 @@ class _OffersPageState extends State with SingleTickerProviderStateM } } - Future _fetchNotificationCount() async { - if (!_isConnectedToInternet) return; - try { - const storage = FlutterSecureStorage(); - final token = await storage.read(key: 'accessToken'); - if (token == null) return; - - final dio = Dio(); - final response = await dio.get( - '${ApiConfig.baseUrl}/notify/get', - options: Options(headers: {'Authorization': 'Bearer $token'}), - ); - - if (response.statusCode == 200 && mounted) { - final List data = response.data['data']; - setState(() { - _notificationCount = data.length; - }); - } - } catch (e) { - debugPrint("Error fetching notification count: $e"); + Future _subscribeToUserOffersOnLoad() async { + final storage = const FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + if (userID != null && mounted) { + _subscribeToUserOffers(userID); } } void _initLocationListener() { _checkInitialGpsStatus(); - _locationServiceSubscription = Geolocator.getServiceStatusStream().listen((status) { + _locationServiceSubscription = + Geolocator.getServiceStatusStream().listen((status) { final isEnabled = status == ServiceStatus.enabled; if (mounted && _isGpsEnabled != isEnabled) { - setState(() => _isGpsEnabled = isEnabled); - if (!isEnabled) context.read().add(ClearOffers()); + setState(() { + _isGpsEnabled = isEnabled; + }); + if (isEnabled) { + _startSendingLocationUpdates(); + } else { + debugPrint("❌ Location Service Disabled. Stopping updates."); + _locationTimer?.cancel(); + context.read().add(ClearOffers()); + } } }); } Future _checkInitialGpsStatus() async { final status = await Geolocator.isLocationServiceEnabled(); - if (mounted) setState(() => _isGpsEnabled = status); + if (mounted) { + setState(() { + _isGpsEnabled = status; + }); + if (_isGpsEnabled) { + _startSendingLocationUpdates(); + } + } + } + + void _startSendingLocationUpdates() { + debugPrint("🚀 Starting periodic location updates."); + _locationTimer?.cancel(); + _locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) { + _sendLocationUpdate(); + }); + _sendLocationUpdate(); + } + + Future _sendLocationUpdate() async { + if (!_isConnectedToInternet || !_isGpsEnabled) return; + + final mqttService = context.read(); + if (!mqttService.isConnected) { + debugPrint("⚠️ MQTT not connected in OffersPage. Cannot send location."); + return; + } + + try { + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint("🚫 Location permission denied by user."); + return; + } + + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + + if (userID == null) { + debugPrint("⚠️ UserID not found. Cannot send location."); + return; + } + + final payload = { + "userID": userID, + "lat": position.latitude, + "lng": position.longitude + }; + + mqttService.publish("proxybuy/sendGps", payload); + } catch (e) { + debugPrint("❌ Error sending location update in OffersPage: $e"); + } + } + + void _subscribeToUserOffers(String userID) { + if (_isSubscribedToOffers) return; + + final mqttService = context.read(); + if (!mqttService.isConnected) { + debugPrint("⚠️ Cannot subscribe. MQTT client is not connected."); + return; + } + + final topic = 'user-proxybuy/$userID'; + mqttService.subscribe(topic); + _isSubscribedToOffers = true; + + _mqttMessageSubscription = mqttService.messages.listen((message) { + final data = message['data']; + + if (data == null || data is! List) { + if (mounted) { + context.read().add(const OffersReceivedFromMqtt([])); + } + return; + } + + try { + List offers = data + .whereType>() + .map((json) => OfferModel.fromJson(json)) + .toList(); + + if (mounted) { + context.read().add(OffersReceivedFromMqtt(offers)); + } + } catch (e, stackTrace) { + debugPrint("❌ Error parsing offers from MQTT: $e"); + debugPrint(stackTrace.toString()); + } + }); } Future _loadPreferences() async { final prefs = await SharedPreferences.getInstance(); - final savedCategories = prefs.getStringList('user_selected_categories') ?? []; - if (mounted) setState(() => _selectedCategories = savedCategories); + final savedCategories = + prefs.getStringList('user_selected_categories') ?? []; + + if (mounted) { + setState(() { + _selectedCategories = savedCategories; + }); + } } - Future _handleRefresh() { - final completer = Completer(); - final service = FlutterBackgroundService(); - final timeout = Timer(const Duration(seconds: 20), () { - if (!completer.isCompleted) { - completer.completeError('Request timed out.'); - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Request timed out. Please try again.'))); + Future _fetchNotificationCount() async { + try { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'accessToken'); + if (token == null) { + if (mounted) setState(() => _notificationCount = 0); + return; } - }); - final StreamSubscription?> subscription = service.on('update').listen((event) { - if (!completer.isCompleted) completer.complete(); - }); - completer.future.whenComplete(() { - subscription.cancel(); - timeout.cancel(); - }); - service.invoke('force_refresh'); - return completer.future; + 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() { @@ -283,20 +438,36 @@ class _OffersPageState extends State with SingleTickerProviderStateM Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('دسته‌بندی‌های مورد علاقه شما', style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold)), + const Text( + 'دسته‌بندی‌های مورد علاقه شما', + style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold), + ), TextButton( onPressed: () async { - final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const NotificationPreferencesPage(loadFavoritesOnStart: true))); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const NotificationPreferencesPage( + loadFavoritesOnStart: true, + ), + ), + ); + if (!mounted) return; - context.read().add(ResetSubmissionStatus()); - await _loadPreferences(); - if (result == true) _handleRefresh(); + + context + .read() + .add(ResetSubmissionStatus()); + + _loadPreferences(); }, child: Row( children: [ SvgPicture.asset(Assets.icons.edit.path), const SizedBox(width: 4), - const Text('ویرایش', style: TextStyle(color: AppColors.active)), + const Text( + 'ویرایش', + style: TextStyle(color: AppColors.active), + ), ], ), ), @@ -307,7 +478,10 @@ class _OffersPageState extends State with SingleTickerProviderStateM if (_selectedCategories.isEmpty) const Padding( padding: EdgeInsets.only(bottom: 8.0), - child: Text('شما هنوز دسته‌بندی مورد علاقه خود را انتخاب نکرده‌اید.', style: TextStyle(color: Colors.grey)), + child: Text( + 'شما هنوز دسته‌بندی مورد علاقه خود را انتخاب نکرده‌اید.', + style: TextStyle(color: Colors.grey), + ), ) else Wrap( @@ -315,8 +489,14 @@ class _OffersPageState extends State with SingleTickerProviderStateM runSpacing: 8.0, children: _selectedCategories.map((category) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), - decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(20.0)), + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 6.0, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(20.0), + ), child: Text(category), ); }).toList(), @@ -335,31 +515,52 @@ class _OffersPageState extends State with SingleTickerProviderStateM backgroundColor: Colors.white, automaticallyImplyLeading: false, title: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 0.0), + padding: const EdgeInsets.symmetric( + horizontal: 15.0, + vertical: 0.0, + ), child: Assets.icons.logoWithName.svg(height: 40, width: 200), ), actions: [ + // Notification bell with badge and overlay trigger Stack( + clipBehavior: Clip.none, alignment: Alignment.center, children: [ IconButton( - key: _notificationIconKey, - onPressed: _toggleNotificationPanel, + key: _bellKey, + onPressed: () { + if (_notifVisible) { + _hideNotificationOverlay(); + } else { + _fetchNotificationCount(); + _showNotificationOverlay(); + } + }, icon: Assets.icons.notification.svg(), ), if (_notificationCount > 0) Positioned( - top: 0, - right: 2, - child: GestureDetector( - onTap: _toggleNotificationPanel, - 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: Padding( - padding: const EdgeInsets.fromLTRB(2, 4, 2, 2), - child: Text('$_notificationCount', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), textAlign: TextAlign.center), + 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, ), ), ), @@ -373,11 +574,13 @@ class _OffersPageState extends State with SingleTickerProviderStateM alignment: Alignment.center, children: [ IconButton( - onPressed: () => Navigator.of(context).push(PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const ReservedListPage(), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - SharedAxisTransition(animation: animation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: child), - )), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ReservedListPage(), + ), + ); + }, icon: Assets.icons.scanBarcode.svg(), ), if (reservedCount > 0) @@ -385,18 +588,36 @@ class _OffersPageState extends State with SingleTickerProviderStateM top: 0, right: 2, child: GestureDetector( - onTap: () => Navigator.of(context).push(PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => const ReservedListPage(), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - SharedAxisTransition(animation: animation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: child), - )), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ReservedListPage(), + ), + ); + }, child: Container( padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 1.5)), - constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: + Border.all(color: Colors.white, width: 1.5), + ), + constraints: const BoxConstraints( + minWidth: 18, + minHeight: 18, + ), child: Padding( padding: const EdgeInsets.fromLTRB(2, 4, 2, 2), - child: Text('$reservedCount', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), textAlign: TextAlign.center), + child: Text( + '$reservedCount', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), ), ), ), @@ -408,115 +629,36 @@ class _OffersPageState extends State with SingleTickerProviderStateM const SizedBox(width: 8), ], ), - body: Stack( - children: [ - RefreshIndicator( - onRefresh: _handleRefresh, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFavoriteCategoriesSection(), - OffersView( - isGpsEnabled: _isGpsEnabled, - isConnectedToInternet: _isConnectedToInternet, - selectedCategories: _selectedCategories, - onRandomSearch: _handleRandomSearch, - isSearchingRandomly: _isSearchingRandomly, - ), - ], + body: RefreshIndicator( + onRefresh: _onRefresh, + color: AppColors.active, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFavoriteCategoriesSection(), + OffersView( + isGpsEnabled: _isGpsEnabled, + isConnectedToInternet: _isConnectedToInternet, ), - ), + ], ), - _buildNotificationOverlay(), - ], + ), ), ), ); } - - Widget _buildNotificationOverlay() { - if (!_showNotificationPanel && _animationController.isDismissed) { - return const SizedBox.shrink(); - } - - final RenderBox? renderBox = _notificationIconKey.currentContext?.findRenderObject() as RenderBox?; - if (renderBox == null) return const SizedBox.shrink(); - - final position = renderBox.localToGlobal(Offset.zero); - final size = renderBox.size; - final iconCenter = Offset(position.dx + size.width / 2, position.dy + size.height / 2); - - final screenHeight = MediaQuery.of(context).size.height; - final screenWidth = MediaQuery.of(context).size.width; - final maxRadius = math.sqrt(math.pow(screenWidth, 2) + math.pow(screenHeight, 2)); - - _animation = Tween(begin: 0.0, end: maxRadius).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeInCubic), - ); - - return AnimatedBuilder( - animation: _animation, - builder: (context, child) { - return Stack( - children: [ - if (_showNotificationPanel || !_animationController.isDismissed) - Positioned.fill( - child: GestureDetector( - onTap: _toggleNotificationPanel, - child: Container( - color: Colors.black.withOpacity(0.4 * _animationController.value), - ), - ), - ), - ClipPath( - clipper: CircularRevealClipper( - radius: _animation.value, - center: iconCenter, - ), - child: Align( - alignment: Alignment.topLeft, - child: Padding( - padding: const EdgeInsets.only(top: kToolbarHeight - 40, left: 16, right: 16), - child: Material( - elevation: 12.0, - borderRadius: BorderRadius.circular(16), - child: Container( - height: MediaQuery.of(context).size.height * 0.40, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: NotificationPanel( - onClose: _toggleNotificationPanel, - ), - ), - ), - ), - ), - ), - ], - ); - }, - ); - } } class OffersView extends StatelessWidget { final bool isGpsEnabled; final bool isConnectedToInternet; - final List selectedCategories; - final VoidCallback onRandomSearch; - final bool isSearchingRandomly; const OffersView({ super.key, required this.isGpsEnabled, required this.isConnectedToInternet, - required this.selectedCategories, - required this.onRandomSearch, - required this.isSearchingRandomly, }); @override @@ -548,11 +690,7 @@ class OffersView extends StatelessWidget { } if (state is OffersLoadSuccess) { - final filteredOffers = selectedCategories.isEmpty - ? state.offers - : state.offers.where((offer) => selectedCategories.contains(offer.category)).toList(); - - if (filteredOffers.isEmpty) { + if (state.offers.isEmpty) { return const SizedBox( height: 300, child: Center( @@ -567,7 +705,10 @@ class OffersView extends StatelessWidget { ); } - final groupedOffers = groupBy(filteredOffers, (OfferModel offer) => offer.category); + final groupedOffers = groupBy( + state.offers, + (OfferModel offer) => offer.category, + ); final categories = groupedOffers.keys.toList(); return ListView.builder( @@ -578,10 +719,12 @@ class OffersView extends StatelessWidget { itemBuilder: (context, index) { final category = categories[index]; final offersForCategory = groupedOffers[category]!; - return CategoryOffersRow(categoryTitle: category, offers: offersForCategory) - .animate() - .fade(duration: 500.ms) - .slideY(begin: 0.3, duration: 400.ms, curve: Curves.easeOut); + + return CategoryOffersRow( + categoryTitle: category, + offers: offersForCategory, + ).animate().fade(duration: 500.ms).slideY( + begin: 0.3, duration: 400.ms, curve: Curves.easeOut); }, ); } @@ -616,33 +759,25 @@ class OffersView extends StatelessWidget { backgroundColor: AppColors.confirm, foregroundColor: Colors.white, disabledBackgroundColor: Colors.grey, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 125), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 125, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + ), + child: const Text( + 'فعال‌سازی GPS', + style: TextStyle( + fontFamily: 'Dana', + fontSize: 16, + fontWeight: FontWeight.normal, + ), ), - child: const Text('فعال‌سازی GPS', style: TextStyle(fontFamily: 'Dana', fontSize: 16, fontWeight: FontWeight.normal)), ), const SizedBox(height: 15), - InkWell( - onTap: isSearchingRandomly ? null : onRandomSearch, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: isSearchingRandomly - ? const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('در حال جستجو...'), - SizedBox(width: 8), - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ) - : const Text('جست‌وجوی تصادفی'), - ), - ), + const Text('جست‌وجوی تصادفی'), ], ), ), @@ -659,29 +794,18 @@ class OffersView extends StatelessWidget { children: [ Icon(Icons.wifi_off_rounded, size: 80, color: Colors.grey[400]), const SizedBox(height: 20), - const Text("اتصال به اینترنت برقرار نیست", style: TextStyle(fontSize: 18, color: Colors.grey)), + const Text( + "اتصال به اینترنت برقرار نیست", + style: TextStyle(fontSize: 18, color: Colors.grey), + ), const SizedBox(height: 10), - const Text("لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.", style: TextStyle(color: Colors.grey)), + const Text( + "لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.", + style: TextStyle(color: Colors.grey), + ), ], ), ), ); } -} - -class CircularRevealClipper extends CustomClipper { - final double radius; - final Offset center; - - CircularRevealClipper({required this.radius, required this.center}); - - @override - Path getClip(Size size) { - return Path()..addOval(Rect.fromCircle(center: center, radius: radius)); - } - - @override - bool shouldReclip(covariant CustomClipper oldClipper) { - return true; - } } \ No newline at end of file diff --git a/lib/presentation/widgets/notification_panel.dart b/lib/presentation/widgets/notification_panel.dart index 00db1c2..f7cbb61 100644 --- a/lib/presentation/widgets/notification_panel.dart +++ b/lib/presentation/widgets/notification_panel.dart @@ -3,7 +3,6 @@ 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/api_config.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'; @@ -12,8 +11,9 @@ 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}); + const NotificationPanel({super.key, required this.onClose, this.onListChanged}); @override State createState() => _NotificationPanelState(); @@ -40,27 +40,32 @@ class _NotificationPanelState extends State { _isLoading = false; _errorMessage = 'برای مشاهده اعلان‌ها، لطفا ابتدا وارد شوید.'; }); + widget.onListChanged?.call(); } return; } try { final response = await _dio.get( - '${ApiConfig.baseUrl}/notify/get', + 'https://proxybuy.liara.run/notify/get', options: Options(headers: {'Authorization': 'Bearer $token'}), ); if (response.statusCode == 200 && mounted) { - final List data = response.data['data']; + final List data = response.data['data'] ?? []; setState(() { _notifications = data.map((json) => NotificationModel.fromJson(json)).toList(); _isLoading = false; }); - } else { - setState(() { - _isLoading = false; - _errorMessage = 'خطا در دریافت اطلاعات.'; - }); + widget.onListChanged?.call(); + } else if (response.statusCode == 201) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'اعلانی وجود ندارد'; + }); + widget.onListChanged?.call(); + } } } catch (e) { if (mounted) { @@ -68,6 +73,7 @@ class _NotificationPanelState extends State { _isLoading = false; _errorMessage = 'اتصال به سرور برقرار نشد.'; }); + widget.onListChanged?.call(); } } } @@ -83,7 +89,7 @@ class _NotificationPanelState extends State { try { final response = await _dio.get( - '${ApiConfig.baseUrl}/notify/ignore/$notificationId', + 'https://proxybuy.liara.run/notify/ignore/$notificationId', options: Options(headers: {'Authorization': 'Bearer $token'}), ); @@ -91,17 +97,20 @@ class _NotificationPanelState extends State { setState(() { _notifications.removeWhere((n) => n.id == notificationId); }); + widget.onListChanged?.call(); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(response.data['message'] ?? 'خطا در حذف اعلان.')), - ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response.data?['message'] ?? 'خطا در حذف اعلان.')), + ); + } } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('خطا در ارتباط با سرور.')), ); - print(e.toString()); + debugPrint('Error ignoring notification: $e'); } } } @@ -174,7 +183,7 @@ class _NotificationPanelState extends State { return const Center( child: Padding( padding: EdgeInsets.all(16.0), - child: Text('هیچ اعلانی برای نمایش وجود ندارد.', textAlign: TextAlign.center), + child: Text('اعلانی وجود ندارد.', textAlign: TextAlign.center), ), ); } diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart index 2f62af8..6b85d42 100644 --- a/lib/services/mqtt_service.dart +++ b/lib/services/mqtt_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -8,25 +9,22 @@ 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(); + final StreamController> _messageStreamController = + StreamController.broadcast(); + Stream> get messages => _messageStreamController.stream; - - Completer>? _firstMessageCompleter; - bool get isConnected => client?.connectionStatus?.state == MqttConnectionState.connected; - - Future> awaitFirstMessage() { - _firstMessageCompleter = Completer>(); - return _firstMessageCompleter!.future; + bool get isConnected { + return client?.connectionStatus?.state == MqttConnectionState.connected; } Future connect(String token) async { const storage = FlutterSecureStorage(); final userID = await storage.read(key: 'userID'); - final String clientId = userID ?? 'proxibuy'; + final String clientId = userID?? + 'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0'); final String username = 'ignored'; final String password = token; @@ -36,6 +34,10 @@ class MqttService { client!.autoReconnect = true; client!.setProtocolV311(); + debugPrint('--- [MQTT] Attempting to connect...'); + debugPrint('--- [MQTT] Server: $server:$port'); + debugPrint('--- [MQTT] ClientID: $clientId'); + final connMessage = MqttConnectMessage() .withClientIdentifier(clientId) .startClean() @@ -47,57 +49,86 @@ class MqttService { debugPrint('✅ [MQTT] Connected successfully.'); client!.updates!.listen((List> c) { final MqttPublishMessage recMess = c[0].payload as MqttPublishMessage; - final String payload = MqttPublishPayload.bytesToStringAsString(recMess.payload.message); + + final String payload = + MqttPublishPayload.bytesToStringAsString(recMess.payload.message); + + debugPrint('<<<<< [MQTT] Received Data <<<<<'); + debugPrint('<<<<< [MQTT] Topic: ${c[0].topic}'); + debugPrint('<<<<< [MQTT] Payload as String: $payload'); + debugPrint('<<<<< ======================== <<<<<'); try { final Map jsonPayload = json.decode(payload); - - if (!_messageStreamController.isClosed) { - _messageStreamController.add(jsonPayload); - } - - if (_firstMessageCompleter != null && !_firstMessageCompleter!.isCompleted) { - _firstMessageCompleter!.complete(jsonPayload); - } + _messageStreamController.add(jsonPayload); } catch (e) { - debugPrint("❌ [MQTT] Error decoding JSON: $e"); - if (_firstMessageCompleter != null && !_firstMessageCompleter!.isCompleted) { - _firstMessageCompleter!.completeError(e); - } + debugPrint("❌ [MQTT] Error decoding received JSON: $e"); } }); }; - client!.onDisconnected = () => debugPrint('❌ [MQTT] Disconnected.'); - client!.onSubscribed = (String topic) => debugPrint('✅ [MQTT] Subscribed to topic: $topic'); + client!.onDisconnected = () { + debugPrint('❌ [MQTT] Disconnected.'); + }; + client!.onAutoReconnect = () { + debugPrint('↪️ [MQTT] Auto-reconnecting...'); + }; + + client!.onAutoReconnected = () { + debugPrint('✅ [MQTT] Auto-reconnected successfully.'); + }; + + client!.onSubscribed = (String topic) { + debugPrint('✅ [MQTT] Subscribed to topic: $topic'); + }; + + client!.pongCallback = () { + debugPrint('🏓 [MQTT] Ping response received'); + }; + try { await client!.connect(); + } on NoConnectionException catch (e) { + debugPrint('❌ [MQTT] Connection failed - No Connection Exception: $e'); + client?.disconnect(); + } on SocketException catch (e) { + debugPrint('❌ [MQTT] Connection failed - Socket Exception: $e'); + client?.disconnect(); } catch (e) { - debugPrint('❌ [MQTT] Connection failed: $e'); + debugPrint('❌ [MQTT] Connection failed - General Exception: $e'); client?.disconnect(); - if (_firstMessageCompleter != null && !_firstMessageCompleter!.isCompleted) { - _firstMessageCompleter!.completeError(e); - } } } - + void subscribe(String topic) { if (isConnected) { client?.subscribe(topic, MqttQos.atLeastOnce); + } else { + debugPrint("⚠️ [MQTT] Cannot subscribe. Client is not connected."); } } void publish(String topic, Map message) { if (isConnected) { final builder = MqttClientPayloadBuilder(); - builder.addString(json.encode(message)); + final payloadString = json.encode(message); + builder.addString(payloadString); + + debugPrint('>>>>> [MQTT] Publishing Data >>>>>'); + debugPrint('>>>>> [MQTT] Topic: $topic'); + debugPrint('>>>>> [MQTT] Payload: $payloadString'); + debugPrint('>>>>> ======================= >>>>>'); + client?.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!); + } else { + debugPrint("⚠️ [MQTT] Cannot publish. Client is not connected."); } } void dispose() { - client?.disconnect(); + debugPrint("--- [MQTT] Disposing MQTT Service."); _messageStreamController.close(); + client?.disconnect(); } -} \ No newline at end of file +} \ No newline at end of file -- 2.40.1