proxibuy/lib/presentation/pages/offers_page.dart

582 lines
19 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
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_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_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<OffersPage> createState() => _OffersPageState();
}
class _OffersPageState extends State<OffersPage> {
List<String> _selectedCategories = [];
StreamSubscription? _locationServiceSubscription;
StreamSubscription? _mqttMessageSubscription;
StreamSubscription? _connectivitySubscription;
Timer? _locationTimer;
bool _isSubscribedToOffers = false;
bool _isGpsEnabled = false;
@override
void initState() {
super.initState();
_initializePage();
_initConnectivityListener();
_fetchInitialReservations();
}
Future<void> _fetchInitialReservations() async {
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<dynamic> reserves = response.data['reserves'];
final List<String> reservedIds =
reserves
.map(
(reserveData) =>
(reserveData['Discount']['ID'] as String?) ?? '',
)
.where((id) => id.isNotEmpty)
.toList();
context.read<ReservationCubit>().setReservedIds(reservedIds);
}
} catch (e) {
debugPrint("Error fetching initial reservations: $e");
}
}
Future<void> _initializePage() async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
await showNotificationPermissionDialog(context);
await showGPSDialog(context);
}
});
await _loadPreferences();
_checkAndConnectMqtt();
_initLocationListener();
}
void _initConnectivityListener() {
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((
List<ConnectivityResult> results,
) {
if (!results.contains(ConnectivityResult.none)) {
print(" Network connection restored.");
_checkAndConnectMqtt();
} else {
print(" Network connection lost.");
}
});
}
Future<void> _checkAndConnectMqtt() async {
final mqttService = context.read<MqttService>();
if (!mqttService.isConnected) {
print("--- OffersPage: MQTT not connected. Attempting to connect...");
final storage = const FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token != null && token.isNotEmpty) {
try {
await mqttService.connect(token);
if (mqttService.isConnected && mounted) {
_subscribeToUserOffersOnLoad();
}
} catch (e) {
print("❌ OffersPage: Error connecting to MQTT: $e");
}
}
} else {
print("--- OffersPage: MQTT already connected.");
_subscribeToUserOffersOnLoad();
}
}
@override
void dispose() {
_locationServiceSubscription?.cancel();
_mqttMessageSubscription?.cancel();
_locationTimer?.cancel();
_connectivitySubscription?.cancel();
super.dispose();
}
Future<void> _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,
) {
final isEnabled = status == ServiceStatus.enabled;
if (mounted && _isGpsEnabled != isEnabled) {
setState(() {
_isGpsEnabled = isEnabled;
});
}
if (isEnabled) {
_startSendingLocationUpdates();
} else {
print("❌ Location Service Disabled. Stopping updates.");
_locationTimer?.cancel();
context.read<OffersBloc>().add(ClearOffers());
}
});
}
Future<void> _checkInitialGpsStatus() async {
final status = await Geolocator.isLocationServiceEnabled();
if (mounted) {
setState(() {
_isGpsEnabled = status;
});
if (_isGpsEnabled) {
_startSendingLocationUpdates();
}
}
}
void _startSendingLocationUpdates() {
print("🚀 Starting periodic location updates.");
_locationTimer?.cancel();
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
_sendLocationUpdate();
});
_sendLocationUpdate();
}
Future<void> _sendLocationUpdate() async {
final mqttService = context.read<MqttService>();
if (!mqttService.isConnected) {
print("⚠️ 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) {
print("🚫 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) {
print("⚠️ UserID not found. Cannot send location.");
return;
}
final payload = {"userID": userID, "lat": 32.6685, "lng": 51.6826};
mqttService.publish("proxybuy/sendGps", payload);
} catch (e) {
print("❌ Error sending location update in OffersPage: $e");
}
}
void _subscribeToUserOffers(String userID) {
if (_isSubscribedToOffers) return;
final mqttService = context.read<MqttService>();
final topic = 'user-proxybuy/$userID';
mqttService.subscribe(topic);
_isSubscribedToOffers = true;
_mqttMessageSubscription = mqttService.messages.listen((message) {
final data = message['data'];
if (data == null) {
if (mounted) {
context.read<OffersBloc>().add(const OffersReceivedFromMqtt([]));
}
return;
}
if (data is List) {
try {
List<OfferModel> offers =
data
.whereType<Map<String, dynamic>>()
.map((json) => OfferModel.fromJson(json))
.toList();
if (mounted) {
context.read<OffersBloc>().add(OffersReceivedFromMqtt(offers));
}
} catch (e, stackTrace) {
print("❌ Error parsing offers from MQTT: $e");
print(stackTrace);
}
}
});
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
final savedCategories =
prefs.getStringList('user_selected_categories') ?? [];
if (mounted) {
setState(() {
_selectedCategories = savedCategories;
});
}
}
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 {
await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => const NotificationPreferencesPage(
loadFavoritesOnStart: true,
),
),
);
if (!mounted) return;
context
.read<NotificationPreferencesBloc>()
.add(ResetSubmissionStatus());
_loadPreferences();
},
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: [
IconButton(onPressed: () {}, icon: Assets.icons.notification.svg()),
BlocBuilder<ReservationCubit, ReservationState>(
builder: (context, state) {
final reservedCount = state.reservedProductIds.length;
return Stack(
alignment: Alignment.center,
children: [
IconButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const ReservedListPage(),
),
);
},
icon: Assets.icons.scanBarcode.svg(),
),
if (reservedCount > 0)
Positioned(
top: 0,
right: 2,
child: GestureDetector(
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,
),
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: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFavoriteCategoriesSection(),
OffersView(isGpsEnabled: _isGpsEnabled),
],
),
),
),
);
}
}
class OffersView extends StatelessWidget {
final bool isGpsEnabled;
const OffersView({super.key, required this.isGpsEnabled});
@override
Widget build(BuildContext context) {
return BlocBuilder<OffersBloc, OffersState>(
builder: (context, state) {
if (!isGpsEnabled) {
return _buildGpsActivationUI(context);
}
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) {
if (state.offers.isEmpty) {
return const SizedBox(
height: 300,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("فعلاً تخفیفی در این اطراف نیست!"),
Text("کمی قدم بزنید..."),
],
),
),
);
}
final groupedOffers = groupBy(
state.offers,
(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),
const Text('جست‌وجوی تصادفی'),
],
),
),
),
);
}
}