// 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'; 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/offer_model.dart'; import 'package:proxibuy/presentation/notification_preferences/bloc/notification_preferences_bloc.dart'; import 'package:proxibuy/presentation/notification_preferences/bloc/notification_preferences_event.dart'; import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart'; import 'package:proxibuy/presentation/offer/bloc/offer_event.dart'; import 'package:proxibuy/presentation/offer/bloc/offer_state.dart'; import 'package:proxibuy/presentation/offer/bloc/widgets/category_offers_row.dart'; 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 { final bool showDialogsOnLoad; const OffersPage({super.key, this.showDialogsOnLoad = false}); @override State createState() => _OffersPageState(); } 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); }); } Future _initializePage() async { if (widget.showDialogsOnLoad) { WidgetsBinding.instance.addPostFrameCallback((_) async { if (mounted) { await showNotificationPermissionDialog(context); await showGPSDialog(context); } }); } 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()); } }); } void _initConnectivityListener() { _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { final hasConnection = !results.contains(ConnectivityResult.none); if (mounted && _isConnectedToInternet != hasConnection) { setState(() => _isConnectedToInternet = hasConnection); if (hasConnection) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت برقرار شد.'), backgroundColor: Colors.green)); _fetchNotificationCount(); } else { context.read().add(ClearOffers()); setState(() => _notificationCount = 0); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت قطع شد.'), backgroundColor: Colors.red)); } } }); } Future _fetchInitialReservations() 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 + 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(); context.read().setReservedIds(reservedIds); } } catch (e) { debugPrint("Error fetching initial reservations: $e"); } } 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) { final isEnabled = status == ServiceStatus.enabled; if (mounted && _isGpsEnabled != isEnabled) { setState(() => _isGpsEnabled = isEnabled); if (!isEnabled) context.read().add(ClearOffers()); } }); } Future _checkInitialGpsStatus() async { final status = await Geolocator.isLocationServiceEnabled(); 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); } 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), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ 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))); if (!mounted) return; context.read().add(ResetSubmissionStatus()); await _loadPreferences(); 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 Divider(height: 1), const SizedBox(height: 12), if (_selectedCategories.isEmpty) const Padding( padding: EdgeInsets.only(bottom: 8.0), child: Text('شما هنوز دسته‌بندی مورد علاقه خود را انتخاب نکرده‌اید.', style: TextStyle(color: Colors.grey)), ) else Wrap( spacing: 8.0, 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)), child: Text(category), ); }).toList(), ), ], ), ); } @override Widget build(BuildContext context) { return Directionality( textDirection: TextDirection.rtl, child: Scaffold( appBar: AppBar( backgroundColor: Colors.white, automaticallyImplyLeading: false, title: Padding( padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 0.0), child: Assets.icons.logoWithName.svg(height: 40, width: 200), ), actions: [ 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; return Stack( 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), )), icon: Assets.icons.scanBarcode.svg(), ), if (reservedCount > 0) Positioned( 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), )), 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), 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), ), ), ), ), ], ); }, ), 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, ), ], ), ), ), _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 Widget build(BuildContext context) { if (!isConnectedToInternet) { return _buildNoInternetUI(context); } if (!isGpsEnabled) { return _buildGpsActivationUI(context); } return BlocBuilder( builder: (context, state) { if (state is OffersInitial) { return const SizedBox( height: 300, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 20), Text("در حال یافتن بهترین پیشنهادها برای شما..."), ], ), ), ); } if (state is OffersLoadSuccess) { 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( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text("فعلاً تخفیفی در این اطراف نیست!"), Text("کمی قدم بزنید..."), ], ), ), ); } final groupedOffers = groupBy(filteredOffers, (OfferModel offer) => offer.category); final categories = groupedOffers.keys.toList(); return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.only(top: 16), itemCount: categories.length, 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); }, ); } if (state is OffersLoadFailure) { return SizedBox( height: 200, child: Center(child: Text("خطا در بارگذاری: ${state.error}")), ); } return const SizedBox.shrink(); }, ); } Widget _buildGpsActivationUI(BuildContext context) { return Center( child: SizedBox( child: Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(height: 85), SvgPicture.asset(Assets.images.emptyHome.path), const SizedBox(height: 60), ElevatedButton( onPressed: () async { await Geolocator.openLocationSettings(); }, style: ElevatedButton.styleFrom( 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)), ), 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('جست‌وجوی تصادفی'), ), ), ], ), ), ), ); } Widget _buildNoInternetUI(BuildContext context) { return SizedBox( height: 300, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, 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 SizedBox(height: 10), 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; } }