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)