diff --git a/lib/core/config/app_colors.dart b/lib/core/config/app_colors.dart index 5fbbd02..6eaa2f8 100644 --- a/lib/core/config/app_colors.dart +++ b/lib/core/config/app_colors.dart @@ -17,4 +17,6 @@ class AppColors { static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251); static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28); static const Color uploadElevated = Color.fromARGB(255, 233, 245, 254); + static const Color backgroundConfirm = Color.fromARGB(255, 237, 247, 238); + static const Color notifIcon = Color.fromARGB(255, 179, 38, 30); } \ No newline at end of file diff --git a/lib/data/models/notification_model.dart b/lib/data/models/notification_model.dart new file mode 100644 index 0000000..109be51 --- /dev/null +++ b/lib/data/models/notification_model.dart @@ -0,0 +1,37 @@ +import 'package:proxibuy/data/models/offer_model.dart'; + +class NotificationModel { + final String id; + final String description; + final DateTime createdAt; + final String discountId; + final String discountName; + final String shopName; + final bool status; + OfferModel? offer; + + NotificationModel({ + required this.id, + required this.description, + required this.createdAt, + required this.discountId, + required this.discountName, + required this.shopName, + required this.status, + this.offer, + }); + + factory NotificationModel.fromJson(Map json) { + final bool statusValue = json['Status'] is bool ? json['Status'] : false; + + return NotificationModel( + id: json['ID'] ?? '', + description: json['Description'] ?? 'No description available.', + createdAt: DateTime.parse(json['createdAt'] ?? DateTime.now().toIso8601String()), + discountId: json['Discount']?['ID'] ?? '', + discountName: json['Discount']?['Name'] ?? 'Unknown Discount', + shopName: json['Discount']?['Shop']?['Name'] ?? 'Unknown Shop', + status: statusValue, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart index af19983..679ff79 100644 --- a/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart +++ b/lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart @@ -1,5 +1,3 @@ -// lib/presentation/notification_preferences/bloc/notification_preferences_bloc.dart - import 'package:dio/dio.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; diff --git a/lib/presentation/pages/notification_preferences_page.dart b/lib/presentation/pages/notification_preferences_page.dart index 0e27f2c..cce381d 100644 --- a/lib/presentation/pages/notification_preferences_page.dart +++ b/lib/presentation/pages/notification_preferences_page.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; diff --git a/lib/presentation/pages/offers_page.dart b/lib/presentation/pages/offers_page.dart index 0ba60b7..bd8ba99 100644 --- a/lib/presentation/pages/offers_page.dart +++ b/lib/presentation/pages/offers_page.dart @@ -1,5 +1,8 @@ +// lib/presentation/pages/offers_page.dart import 'dart:async'; +import 'dart:math' as math; // برای محاسبات ریاضی +import 'package:animations/animations.dart'; import 'package:collection/collection.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; @@ -24,7 +27,9 @@ import 'package:proxibuy/presentation/pages/notification_preferences_page.dart'; import 'package:proxibuy/presentation/pages/reserved_list_page.dart'; import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart'; import 'package:proxibuy/presentation/widgets/gps_dialog.dart'; +import 'package:proxibuy/presentation/widgets/notification_panel.dart'; import 'package:proxibuy/presentation/widgets/notification_permission_dialog.dart'; +import 'package:proxibuy/services/mqtt_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class OffersPage extends StatefulWidget { @@ -36,35 +41,99 @@ class OffersPage extends StatefulWidget { State createState() => _OffersPageState(); } -class _OffersPageState extends State { +class _OffersPageState extends State with SingleTickerProviderStateMixin { List _selectedCategories = []; StreamSubscription? _locationServiceSubscription; StreamSubscription? _connectivitySubscription; bool _isGpsEnabled = false; bool _isConnectedToInternet = true; + bool _isSearchingRandomly = false; + + bool _showNotificationPanel = false; + final GlobalKey _notificationIconKey = GlobalKey(); + late AnimationController _animationController; + late Animation _animation; + int _notificationCount = 0; @override void initState() { super.initState(); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _checkInitialConnectivity(); _initializePage(); _initConnectivityListener(); _fetchInitialReservations(); + _fetchNotificationCount(); } @override void dispose() { _locationServiceSubscription?.cancel(); _connectivitySubscription?.cancel(); + _animationController.dispose(); super.dispose(); } + void _toggleNotificationPanel() { + setState(() { + _showNotificationPanel = !_showNotificationPanel; + if (_showNotificationPanel) { + _animationController.forward(); + } else { + _animationController.reverse(); + _fetchNotificationCount(); + } + }); + } + + Future _handleRandomSearch() async { + setState(() => _isSearchingRandomly = true); + + const storage = FlutterSecureStorage(); + final mqttService = context.read(); + final userID = await storage.read(key: 'userID'); + + if (!mounted) return; + + if (userID == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('برای این کار باید وارد حساب کاربری خود شوید.')), + ); + setState(() => _isSearchingRandomly = false); + return; + } + + if (!mqttService.isConnected) { + final token = await storage.read(key: 'accessToken'); + if (token != null) { + await mqttService.connect(token); + } else { + setState(() => _isSearchingRandomly = false); + return; + } + } + + final randomTopic = 'user-proxybuy-random/$userID'; + mqttService.subscribe(randomTopic); + mqttService.publish('proxybuy-random', {'userID': userID}); + + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() => _isSearchingRandomly = false); + } + }); + } + Future _checkInitialConnectivity() async { final connectivityResult = await Connectivity().checkConnectivity(); if (!mounted) return; setState(() { - _isConnectedToInternet = - !connectivityResult.contains(ConnectivityResult.none); + _isConnectedToInternet = !connectivityResult.contains(ConnectivityResult.none); }); } @@ -85,7 +154,6 @@ class _OffersPageState extends State { void _listenToBackgroundService() { FlutterBackgroundService().on('update').listen((event) { if (event == null || event['offers'] == null) return; - final data = event['offers']['data']; if (data == null || data is! List) { if (mounted) { @@ -93,13 +161,8 @@ class _OffersPageState extends State { } return; } - try { - List offers = data - .whereType>() - .map((json) => OfferModel.fromJson(json)) - .toList(); - + List offers = data.whereType>().map((json) => OfferModel.fromJson(json)).toList(); if (mounted) { context.read().add(OffersReceivedFromMqtt(offers)); } @@ -111,29 +174,17 @@ class _OffersPageState extends State { } void _initConnectivityListener() { - _connectivitySubscription = - Connectivity().onConnectivityChanged.listen((results) { + _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { final hasConnection = !results.contains(ConnectivityResult.none); if (mounted && _isConnectedToInternet != hasConnection) { - setState(() { - _isConnectedToInternet = hasConnection; - }); - + setState(() => _isConnectedToInternet = hasConnection); if (hasConnection) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('اتصال به اینترنت برقرار شد.'), - backgroundColor: Colors.green, - ), - ); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت برقرار شد.'), backgroundColor: Colors.green)); + _fetchNotificationCount(); } else { context.read().add(ClearOffers()); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('اتصال به اینترنت قطع شد.'), - backgroundColor: Colors.red, - ), - ); + setState(() => _notificationCount = 0); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اتصال به اینترنت قطع شد.'), backgroundColor: Colors.red)); } } }); @@ -145,21 +196,11 @@ class _OffersPageState extends State { const storage = FlutterSecureStorage(); final token = await storage.read(key: 'accessToken'); if (token == null) return; - final dio = Dio(); - final response = await dio.get( - ApiConfig.baseUrl + ApiConfig.getReservations, - options: Options(headers: {'Authorization': 'Bearer $token'}), - ); - + final response = await dio.get(ApiConfig.baseUrl + ApiConfig.getReservations, options: Options(headers: {'Authorization': 'Bearer $token'})); if (response.statusCode == 200 && mounted) { final List reserves = response.data['reserves']; - final List reservedIds = reserves - .map((reserveData) => - (reserveData['Discount']['ID'] as String?) ?? '') - .where((id) => id.isNotEmpty) - .toList(); - + final List reservedIds = reserves.map((reserveData) => (reserveData['Discount']['ID'] as String?) ?? '').where((id) => id.isNotEmpty).toList(); context.read().setReservedIds(reservedIds); } } catch (e) { @@ -167,73 +208,69 @@ class _OffersPageState extends State { } } + Future _fetchNotificationCount() async { + if (!_isConnectedToInternet) return; + try { + const storage = FlutterSecureStorage(); + final token = await storage.read(key: 'accessToken'); + if (token == null) return; + + final dio = Dio(); + final response = await dio.get( + '${ApiConfig.baseUrl}/notify/get', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && mounted) { + final List data = response.data['data']; + setState(() { + _notificationCount = data.length; + }); + } + } catch (e) { + debugPrint("Error fetching notification count: $e"); + } + } + void _initLocationListener() { _checkInitialGpsStatus(); - _locationServiceSubscription = - Geolocator.getServiceStatusStream().listen((status) { + _locationServiceSubscription = Geolocator.getServiceStatusStream().listen((status) { final isEnabled = status == ServiceStatus.enabled; if (mounted && _isGpsEnabled != isEnabled) { - setState(() { - _isGpsEnabled = isEnabled; - }); - if (!isEnabled) { - context.read().add(ClearOffers()); - } + setState(() => _isGpsEnabled = isEnabled); + if (!isEnabled) context.read().add(ClearOffers()); } }); } Future _checkInitialGpsStatus() async { final status = await Geolocator.isLocationServiceEnabled(); - if (mounted) { - setState(() { - _isGpsEnabled = status; - }); - } + if (mounted) setState(() => _isGpsEnabled = status); } Future _loadPreferences() async { final prefs = await SharedPreferences.getInstance(); - final savedCategories = - prefs.getStringList('user_selected_categories') ?? []; - - if (mounted) { - setState(() { - _selectedCategories = savedCategories; - }); - } + final savedCategories = prefs.getStringList('user_selected_categories') ?? []; + if (mounted) setState(() => _selectedCategories = savedCategories); } Future _handleRefresh() { final completer = Completer(); final service = FlutterBackgroundService(); - final timeout = Timer(const Duration(seconds: 20), () { if (!completer.isCompleted) { completer.completeError('Request timed out.'); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Request timed out. Please try again.')), - ); - } + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Request timed out. Please try again.'))); } }); - - final StreamSubscription?> subscription = - service.on('update').listen((event) { - if (!completer.isCompleted) { - completer.complete(); - } + final StreamSubscription?> subscription = service.on('update').listen((event) { + if (!completer.isCompleted) completer.complete(); }); - completer.future.whenComplete(() { subscription.cancel(); timeout.cancel(); }); - service.invoke('force_refresh'); - return completer.future; } @@ -246,40 +283,20 @@ class _OffersPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'دسته‌بندی‌های مورد علاقه شما', - style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold), - ), + const Text('دسته‌بندی‌های مورد علاقه شما', style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold)), TextButton( onPressed: () async { - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const NotificationPreferencesPage( - loadFavoritesOnStart: true, - ), - ), - ); - + final result = await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const NotificationPreferencesPage(loadFavoritesOnStart: true))); if (!mounted) return; - - context - .read() - .add(ResetSubmissionStatus()); - + context.read().add(ResetSubmissionStatus()); await _loadPreferences(); - - if (result == true) { - _handleRefresh(); - } + if (result == true) _handleRefresh(); }, child: Row( children: [ SvgPicture.asset(Assets.icons.edit.path), const SizedBox(width: 4), - const Text( - 'ویرایش', - style: TextStyle(color: AppColors.active), - ), + const Text('ویرایش', style: TextStyle(color: AppColors.active)), ], ), ), @@ -290,10 +307,7 @@ class _OffersPageState extends State { if (_selectedCategories.isEmpty) const Padding( padding: EdgeInsets.only(bottom: 8.0), - child: Text( - 'شما هنوز دسته‌بندی مورد علاقه خود را انتخاب نکرده‌اید.', - style: TextStyle(color: Colors.grey), - ), + child: Text('شما هنوز دسته‌بندی مورد علاقه خود را انتخاب نکرده‌اید.', style: TextStyle(color: Colors.grey)), ) else Wrap( @@ -301,14 +315,8 @@ class _OffersPageState extends State { runSpacing: 8.0, children: _selectedCategories.map((category) { return Container( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 6.0, - ), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(20.0), - ), + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(20.0)), child: Text(category), ); }).toList(), @@ -327,15 +335,37 @@ class _OffersPageState extends State { backgroundColor: Colors.white, automaticallyImplyLeading: false, title: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 15.0, - vertical: 0.0, - ), + padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 0.0), child: Assets.icons.logoWithName.svg(height: 40, width: 200), ), actions: [ - IconButton( - onPressed: () {}, icon: Assets.icons.notification.svg()), + Stack( + alignment: Alignment.center, + children: [ + IconButton( + key: _notificationIconKey, + onPressed: _toggleNotificationPanel, + icon: Assets.icons.notification.svg(), + ), + if (_notificationCount > 0) + Positioned( + top: 0, + right: 2, + child: GestureDetector( + onTap: _toggleNotificationPanel, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.red, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 1.5)), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 4, 2, 2), + child: Text('$_notificationCount', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), textAlign: TextAlign.center), + ), + ), + ), + ), + ], + ), BlocBuilder( builder: (context, state) { final reservedCount = state.reservedProductIds.length; @@ -343,13 +373,11 @@ class _OffersPageState extends State { alignment: Alignment.center, children: [ IconButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ReservedListPage(), - ), - ); - }, + onPressed: () => Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => const ReservedListPage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + SharedAxisTransition(animation: animation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: child), + )), icon: Assets.icons.scanBarcode.svg(), ), if (reservedCount > 0) @@ -357,36 +385,18 @@ class _OffersPageState extends State { top: 0, right: 2, child: GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ReservedListPage(), - ), - ); - }, + onTap: () => Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => const ReservedListPage(), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + SharedAxisTransition(animation: animation, secondaryAnimation: secondaryAnimation, transitionType: SharedAxisTransitionType.horizontal, child: child), + )), child: Container( padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: - Border.all(color: Colors.white, width: 1.5), - ), - constraints: const BoxConstraints( - minWidth: 18, - minHeight: 18, - ), + decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 1.5)), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), child: Padding( padding: const EdgeInsets.fromLTRB(2, 4, 2, 2), - child: Text( - '$reservedCount', - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + child: Text('$reservedCount', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), textAlign: TextAlign.center), ), ), ), @@ -398,38 +408,115 @@ class _OffersPageState extends State { const SizedBox(width: 8), ], ), - body: RefreshIndicator( - onRefresh: _handleRefresh, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildFavoriteCategoriesSection(), - OffersView( - isGpsEnabled: _isGpsEnabled, - isConnectedToInternet: _isConnectedToInternet, - selectedCategories: _selectedCategories, + body: Stack( + children: [ + RefreshIndicator( + onRefresh: _handleRefresh, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFavoriteCategoriesSection(), + OffersView( + isGpsEnabled: _isGpsEnabled, + isConnectedToInternet: _isConnectedToInternet, + selectedCategories: _selectedCategories, + onRandomSearch: _handleRandomSearch, + isSearchingRandomly: _isSearchingRandomly, + ), + ], ), - ], + ), ), - ), + _buildNotificationOverlay(), + ], ), ), ); } + + Widget _buildNotificationOverlay() { + if (!_showNotificationPanel && _animationController.isDismissed) { + return const SizedBox.shrink(); + } + + final RenderBox? renderBox = _notificationIconKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return const SizedBox.shrink(); + + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + final iconCenter = Offset(position.dx + size.width / 2, position.dy + size.height / 2); + + final screenHeight = MediaQuery.of(context).size.height; + final screenWidth = MediaQuery.of(context).size.width; + final maxRadius = math.sqrt(math.pow(screenWidth, 2) + math.pow(screenHeight, 2)); + + _animation = Tween(begin: 0.0, end: maxRadius).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInCubic), + ); + + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Stack( + children: [ + if (_showNotificationPanel || !_animationController.isDismissed) + Positioned.fill( + child: GestureDetector( + onTap: _toggleNotificationPanel, + child: Container( + color: Colors.black.withOpacity(0.4 * _animationController.value), + ), + ), + ), + ClipPath( + clipper: CircularRevealClipper( + radius: _animation.value, + center: iconCenter, + ), + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(top: kToolbarHeight - 40, left: 16, right: 16), + child: Material( + elevation: 12.0, + borderRadius: BorderRadius.circular(16), + child: Container( + height: MediaQuery.of(context).size.height * 0.40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: NotificationPanel( + onClose: _toggleNotificationPanel, + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } } class OffersView extends StatelessWidget { final bool isGpsEnabled; final bool isConnectedToInternet; final List selectedCategories; + final VoidCallback onRandomSearch; + final bool isSearchingRandomly; const OffersView({ super.key, required this.isGpsEnabled, required this.isConnectedToInternet, required this.selectedCategories, + required this.onRandomSearch, + required this.isSearchingRandomly, }); @override @@ -463,9 +550,7 @@ class OffersView extends StatelessWidget { if (state is OffersLoadSuccess) { final filteredOffers = selectedCategories.isEmpty ? state.offers - : state.offers - .where((offer) => selectedCategories.contains(offer.category)) - .toList(); + : state.offers.where((offer) => selectedCategories.contains(offer.category)).toList(); if (filteredOffers.isEmpty) { return const SizedBox( @@ -482,10 +567,7 @@ class OffersView extends StatelessWidget { ); } - final groupedOffers = groupBy( - filteredOffers, - (OfferModel offer) => offer.category, - ); + final groupedOffers = groupBy(filteredOffers, (OfferModel offer) => offer.category); final categories = groupedOffers.keys.toList(); return ListView.builder( @@ -496,12 +578,10 @@ class OffersView extends StatelessWidget { itemBuilder: (context, index) { final category = categories[index]; final offersForCategory = groupedOffers[category]!; - - return CategoryOffersRow( - categoryTitle: category, - offers: offersForCategory, - ).animate().fade(duration: 500.ms).slideY( - begin: 0.3, duration: 400.ms, curve: Curves.easeOut); + return CategoryOffersRow(categoryTitle: category, offers: offersForCategory) + .animate() + .fade(duration: 500.ms) + .slideY(begin: 0.3, duration: 400.ms, curve: Curves.easeOut); }, ); } @@ -536,25 +616,33 @@ class OffersView extends StatelessWidget { backgroundColor: AppColors.confirm, foregroundColor: Colors.white, disabledBackgroundColor: Colors.grey, - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 125, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(50), - ), - ), - child: const Text( - 'فعال‌سازی GPS', - style: TextStyle( - fontFamily: 'Dana', - fontSize: 16, - fontWeight: FontWeight.normal, - ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 125), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), ), + child: const Text('فعال‌سازی GPS', style: TextStyle(fontFamily: 'Dana', fontSize: 16, fontWeight: FontWeight.normal)), ), const SizedBox(height: 15), - const Text('جست‌وجوی تصادفی'), + InkWell( + onTap: isSearchingRandomly ? null : onRandomSearch, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: isSearchingRandomly + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('در حال جستجو...'), + SizedBox(width: 8), + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ) + : const Text('جست‌وجوی تصادفی'), + ), + ), ], ), ), @@ -571,18 +659,29 @@ class OffersView extends StatelessWidget { children: [ Icon(Icons.wifi_off_rounded, size: 80, color: Colors.grey[400]), const SizedBox(height: 20), - const Text( - "اتصال به اینترنت برقرار نیست", - style: TextStyle(fontSize: 18, color: Colors.grey), - ), + const Text("اتصال به اینترنت برقرار نیست", style: TextStyle(fontSize: 18, color: Colors.grey)), const SizedBox(height: 10), - const Text( - "لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.", - style: TextStyle(color: Colors.grey), - ), + const Text("لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید.", style: TextStyle(color: Colors.grey)), ], ), ), ); } +} + +class CircularRevealClipper extends CustomClipper { + final double radius; + final Offset center; + + CircularRevealClipper({required this.radius, required this.center}); + + @override + Path getClip(Size size) { + return Path()..addOval(Rect.fromCircle(center: center, radius: radius)); + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) { + return true; + } } \ No newline at end of file diff --git a/lib/presentation/pages/product_detail_page.dart b/lib/presentation/pages/product_detail_page.dart index 0f17ebe..ce5eda5 100644 --- a/lib/presentation/pages/product_detail_page.dart +++ b/lib/presentation/pages/product_detail_page.dart @@ -198,19 +198,15 @@ class _ProductDetailViewState extends State { } Future> _fetchComments() async { - // 1. توکن را از حافظه امن بخوان const storage = FlutterSecureStorage(); final token = await storage.read(key: 'accessToken'); - // 2. اگر توکن وجود نداشت، خطا برگردان - // هرچند کاربر لاگین نکرده معمولا به این صفحه دسترسی ندارد if (token == null) { throw Exception('Authentication token not found!'); } try { final dio = Dio(); - // 3. هدر Authorization را به درخواست اضافه کن final response = await dio.get( ApiConfig.baseUrl + ApiConfig.getComments + widget.offer.id, options: Options(headers: {'Authorization': 'Bearer $token'}), @@ -220,11 +216,9 @@ class _ProductDetailViewState extends State { final List commentsJson = response.data['data']['comments']; return commentsJson.map((json) => CommentModel.fromJson(json)).toList(); } else { - // خطاهای دیگر سرور throw Exception('Failed to load comments with status code: ${response.statusCode}'); } } on DioException catch (e) { - // چاپ خطای کامل Dio برای دیباگ بهتر debugPrint("DioException fetching comments: $e"); if (e.response != null) { debugPrint("Response data: ${e.response?.data}"); @@ -235,7 +229,6 @@ class _ProductDetailViewState extends State { throw Exception('An unknown error occurred: $e'); } } - // ############ END: FIX SECTION ############ void _launchMaps(double lat, double lon, String title) { MapsLauncher.launchCoordinates(lat, lon, title); @@ -408,19 +401,6 @@ class _ProductDetailViewState extends State { ), ); }, - child: Container( - width: 90, - height: 90, - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade400, width: 1.5), - ), - child: Padding( - padding: const EdgeInsets.all(9.0), - child: SvgPicture.asset(Assets.icons.addImg.path), - ), - ), ); } @@ -686,7 +666,7 @@ class _ProductDetailViewState extends State { slideDirection: SlideDirection.up, separator: ':', style: const TextStyle( - fontSize: 50, + fontSize: 45, fontWeight: FontWeight.bold, color: AppColors.countdown, ), @@ -720,12 +700,7 @@ class _ProductDetailViewState extends State { return Center(child: Text('خطا در بارگذاری نظرات. لطفاً صفحه را رفرش کنید.')); } if (!snapshot.hasData || snapshot.data!.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), - child: Text('هنوز نظری برای این تخفیف ثبت نشده است.'), - ), - ); + return SizedBox(); } return CommentsSection(comments: snapshot.data!); }, diff --git a/lib/presentation/widgets/notification_panel.dart b/lib/presentation/widgets/notification_panel.dart new file mode 100644 index 0000000..00db1c2 --- /dev/null +++ b/lib/presentation/widgets/notification_panel.dart @@ -0,0 +1,253 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:intl/intl.dart'; +import 'package:proxibuy/core/config/api_config.dart'; +import 'package:proxibuy/core/config/app_colors.dart'; +import 'package:proxibuy/data/models/notification_model.dart'; +import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart'; +import 'package:proxibuy/presentation/offer/bloc/offer_state.dart'; +import 'package:proxibuy/presentation/pages/product_detail_page.dart'; + +class NotificationPanel extends StatefulWidget { + final VoidCallback onClose; + + const NotificationPanel({super.key, required this.onClose}); + + @override + State createState() => _NotificationPanelState(); +} + +class _NotificationPanelState extends State { + List _notifications = []; + bool _isLoading = true; + String _errorMessage = ''; + final Dio _dio = Dio(); + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + + @override + void initState() { + super.initState(); + _fetchNotifications(); + } + + Future _fetchNotifications() async { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'برای مشاهده اعلان‌ها، لطفا ابتدا وارد شوید.'; + }); + } + return; + } + + try { + final response = await _dio.get( + '${ApiConfig.baseUrl}/notify/get', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && mounted) { + final List data = response.data['data']; + setState(() { + _notifications = data.map((json) => NotificationModel.fromJson(json)).toList(); + _isLoading = false; + }); + } else { + setState(() { + _isLoading = false; + _errorMessage = 'خطا در دریافت اطلاعات.'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = 'اتصال به سرور برقرار نشد.'; + }); + } + } + } + + Future _ignoreNotification(String notificationId) async { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('خطا: شما وارد حساب کاربری نشده‌اید.')), + ); + return; + } + + try { + final response = await _dio.get( + '${ApiConfig.baseUrl}/notify/ignore/$notificationId', + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && mounted) { + setState(() { + _notifications.removeWhere((n) => n.id == notificationId); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response.data['message'] ?? 'خطا در حذف اعلان.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('خطا در ارتباط با سرور.')), + ); + print(e.toString()); + } + } + } + + String _formatTimeAgo(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inSeconds < 60) { + return 'همین الان'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes} دقیقه قبل'; + } else if (difference.inHours < 24) { + return '${difference.inHours} ساعت قبل'; + } else if (difference.inDays < 7) { + return '${difference.inDays} روز قبل'; + } else { + return DateFormat('yyyy/MM/dd', 'fa').format(dateTime); + } + } + + void _navigateToOffer(NotificationModel notification) { + if (!notification.status) return; + + final offersState = context.read().state; + if (offersState is OffersLoadSuccess) { + try { + final offer = offersState.offers.firstWhere((o) => o.id == notification.discountId); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProductDetailPage(offer: offer), + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('این تخفیف در حال حاضر در دسترس نیست.')), + ); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('اطلاعات تخفیف‌ها هنوز بارگذاری نشده است.')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: _buildBody(), + ), + ], + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage.isNotEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(_errorMessage, textAlign: TextAlign.center), + ), + ); + } + if (_notifications.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('هیچ اعلانی برای نمایش وجود ندارد.', textAlign: TextAlign.center), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 0.0), + itemCount: _notifications.length, + itemBuilder: (context, index) { + return _buildNotificationCard(_notifications[index]); + }, + ); + } + + Widget _buildNotificationCard(NotificationModel notification) { + final bool isExpired = !notification.status; + final Color textColor = isExpired ? Colors.grey.shade600 : Colors.black; + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notification.description, + style: TextStyle(fontSize: 15, height: 1.6, color: textColor), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + _formatTimeAgo(notification.createdAt.toLocal()), + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + if (isExpired) + ElevatedButton( + onPressed: null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade300, + foregroundColor: Colors.grey.shade700, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + child: const Text('تخفیف تمام شد'), + ) + else + Row( + children: [ + TextButton( + onPressed: () => _ignoreNotification(notification.id), + style: TextButton.styleFrom(foregroundColor: Colors.red.shade700), + child: const Text('بیخیال'), + ), + const SizedBox(width: 4), + ElevatedButton( + onPressed: () => _navigateToOffer(notification), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.backgroundConfirm, + foregroundColor: AppColors.selectedImg, + side: BorderSide(color: Colors.green.shade200), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16), + ), + child: const Text('بزن بریم'), + ), + ], + ), + ], + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart index 8cac638..2f62af8 100644 --- a/lib/services/mqtt_service.dart +++ b/lib/services/mqtt_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:mqtt_client/mqtt_server_client.dart'; @@ -23,7 +24,9 @@ class MqttService { } Future connect(String token) async { - final String clientId = 'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0'); + const storage = FlutterSecureStorage(); + final userID = await storage.read(key: 'userID'); + final String clientId = userID ?? 'proxibuy'; final String username = 'ignored'; final String password = token; diff --git a/pubspec.lock b/pubspec.lock index 85ae763..df92308 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.4.5" + animations: + dependency: "direct main" + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" archive: dependency: transitive description: @@ -1445,6 +1453,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + skeletons: + dependency: "direct main" + description: + name: skeletons + sha256: "5b2d08ae7f908ee1f7007ca99f8dcebb4bfc1d3cb2143dec8d112a5be5a45c8f" + url: "https://pub.dev" + source: hosted + version: "0.0.3" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 00ae85f..8678823 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,8 @@ dependencies: firebase_messaging: ^15.2.10 firebase_crashlytics: ^4.3.10 flutter_rating_bar: ^4.0.1 + animations: ^2.0.11 + skeletons: ^0.0.3 dev_dependencies: flutter_test: