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