add refresh offer_page

This commit is contained in:
mohamadmahdi jebeli 2025-08-04 10:35:28 +03:30
parent f4cd446cde
commit 608222e8a3
7 changed files with 226 additions and 164 deletions

View File

@ -11,7 +11,7 @@ plugins {
android { android {
namespace = "com.example.proxibuy" namespace = "com.example.proxibuy"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true

View File

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />

View File

@ -13,7 +13,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
late final Dio _dio; late final Dio _dio;
final _storage = const FlutterSecureStorage(); final _storage = const FlutterSecureStorage();
AuthBloc() : super(AuthInitial()) { AuthBloc() : super(AuthUnknown()) {
_dio = Dio(); _dio = Dio();
_dio.interceptors.add( _dio.interceptors.add(
LogInterceptor( LogInterceptor(
@ -44,6 +44,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
} }
} }
Future<void> _onSendOTP(SendOTPEvent event, Emitter<AuthState> emit) async { Future<void> _onSendOTP(SendOTPEvent event, Emitter<AuthState> emit) async {
emit(AuthLoading()); emit(AuthLoading());
try { try {

View File

@ -7,6 +7,8 @@ abstract class AuthState extends Equatable {
List<Object?> get props => []; List<Object?> get props => [];
} }
class AuthUnknown extends AuthState {}
class AuthInitial extends AuthState {} class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {} class AuthLoading extends AuthState {}

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -130,7 +131,7 @@ class _NotificationPreferencesPageState
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
), ),
body: BlocListener<NotificationPreferencesBloc, body: BlocConsumer<NotificationPreferencesBloc,
NotificationPreferencesState>( NotificationPreferencesState>(
listener: (context, state) async { listener: (context, state) async {
if (state.submissionSuccess) { if (state.submissionSuccess) {
@ -157,7 +158,7 @@ class _NotificationPreferencesPageState
); );
} }
} else { } else {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} }
} catch (e) { } catch (e) {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
@ -176,151 +177,151 @@ class _NotificationPreferencesPageState
); );
} }
}, },
child: Padding( builder: (context, state) {
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), return Stack(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Padding(
'دریافت اعلان', padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
style: TextStyle( child: Column(
fontFamily: 'Dana', crossAxisAlignment: CrossAxisAlignment.start,
fontSize: 20, children: [
fontWeight: FontWeight.bold, const Text(
), 'دریافت اعلان',
), style: TextStyle(
const SizedBox(height: 4), fontFamily: 'Dana',
const Divider(), fontSize: 20,
RichText( fontWeight: FontWeight.bold,
text: TextSpan( ),
style: TextStyle(
fontFamily: 'Dana',
fontSize: 14,
color: AppColors.hint,
height: 1.5,
),
children: const <TextSpan>[
TextSpan(
text:
'ترجیح می‌دی از کدام دسته‌بندی‌ها اعلان تخفیف دریافت کنی؟ ',
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ),
TextSpan(text: '(حداقل یک مورد رو انتخاب کن).'), const SizedBox(height: 4),
const Divider(),
RichText(
text: TextSpan(
style: TextStyle(
fontFamily: 'Dana',
fontSize: 14,
color: AppColors.hint,
height: 1.5,
),
children: const <TextSpan>[
TextSpan(
text:
'ترجیح می‌دی از کدام دسته‌بندی‌ها اعلان تخفیف دریافت کنی؟ ',
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
TextSpan(text: '(حداقل یک مورد رو انتخاب کن).'),
],
),
),
const SizedBox(height: 24),
Expanded(
child: Builder(
builder: (context) {
if (state.categories.isEmpty && state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final double horizontalPadding = 24.0;
final double crossAxisSpacing = 16.0;
final int crossAxisCount = 3;
final screenWidth = MediaQuery.of(context).size.width;
final itemWidth = (screenWidth -
(horizontalPadding * 2) -
(crossAxisSpacing * (crossAxisCount - 1))) /
crossAxisCount;
final itemHeight = itemWidth / 0.9;
return SingleChildScrollView(
child: Wrap(
spacing: crossAxisSpacing,
runSpacing: 24.0,
alignment: WrapAlignment.center,
children: state.categories.map((category) {
final isSelected =
state.selectedCategoryIds.contains(category.id);
return SizedBox(
width: itemWidth,
height: itemHeight,
child: CategorySelectionCard(
name: category.name,
icon: category.icon,
isSelected: isSelected,
showSelectableIndicator:
state.selectedCategoryIds.isNotEmpty,
onTap: () {
context.read<NotificationPreferencesBloc>().add(
ToggleCategorySelection(category.id));
},
),
);
}).toList(),
),
);
},
),
),
if (state.selectedCategoryIds.isNotEmpty)
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: state.isLoading
? null
: () async {
final bloc =
context.read<NotificationPreferencesBloc>();
final selectedCategoryNames = bloc
.state.categories
.where((cat) => bloc.state
.selectedCategoryIds
.contains(cat.id))
.map((cat) => cat.name)
.toList();
final prefs =
await SharedPreferences.getInstance();
await prefs.setStringList(
'user_selected_categories',
selectedCategoryNames);
bloc.add(SubmitPreferences());
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.confirm,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.withOpacity(0.5),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
),
child: const Text(
'اعمال',
style: TextStyle(
fontFamily: 'Dana',
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 20),
], ],
), ),
), ),
const SizedBox(height: 24), if (state.isLoading)
Expanded( Container(
child: BlocBuilder<NotificationPreferencesBloc, color: Colors.black.withOpacity(0.4),
NotificationPreferencesState>( child: const Center(
builder: (context, state) { child: CircularProgressIndicator(
if (state.categories.isEmpty && state.isLoading) { color: Colors.white,
return const Center(child: CircularProgressIndicator()); ),
} ),
final double horizontalPadding = 24.0;
final double crossAxisSpacing = 16.0;
final int crossAxisCount = 3;
final screenWidth = MediaQuery.of(context).size.width;
final itemWidth = (screenWidth -
(horizontalPadding * 2) -
(crossAxisSpacing * (crossAxisCount - 1))) /
crossAxisCount;
final itemHeight = itemWidth / 0.9;
return SingleChildScrollView(
child: Wrap(
spacing: crossAxisSpacing,
runSpacing: 24.0,
alignment: WrapAlignment.center,
children: state.categories.map((category) {
final isSelected =
state.selectedCategoryIds.contains(category.id);
return SizedBox(
width: itemWidth,
height: itemHeight,
child: CategorySelectionCard(
name: category.name,
icon: category.icon,
isSelected: isSelected,
showSelectableIndicator:
state.selectedCategoryIds.isNotEmpty,
onTap: () {
context.read<NotificationPreferencesBloc>().add(
ToggleCategorySelection(category.id));
},
),
);
}).toList(),
),
);
},
), ),
),
BlocBuilder<NotificationPreferencesBloc,
NotificationPreferencesState>(
builder: (context, state) {
final areCategoriesSelected =
state.selectedCategoryIds.isNotEmpty;
if (areCategoriesSelected) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: !state.isLoading
? () async {
final bloc =
context.read<NotificationPreferencesBloc>();
final selectedCategoryNames = bloc
.state.categories
.where((cat) => bloc.state
.selectedCategoryIds
.contains(cat.id))
.map((cat) => cat.name)
.toList();
final prefs =
await SharedPreferences.getInstance();
await prefs.setStringList(
'user_selected_categories',
selectedCategoryNames);
bloc.add(SubmitPreferences());
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.confirm,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
),
child: state.isLoading
? const CircularProgressIndicator(
color: Colors.white)
: const Text(
'اعمال',
style: TextStyle(
fontFamily: 'Dana',
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
} else {
return const SizedBox.shrink();
}
},
),
const SizedBox(height: 20),
], ],
), );
), },
), ),
); );
} }

View File

@ -1,4 +1,3 @@
// lib/presentation/pages/offers_page.dart
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -205,6 +204,39 @@ class _OffersPageState extends State<OffersPage> {
} }
} }
Future<void> _handleRefresh() {
final completer = Completer<void>();
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<Map<String, dynamic>?> 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() { Widget _buildFavoriteCategoriesSection() {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0), padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0),
@ -220,7 +252,7 @@ class _OffersPageState extends State<OffersPage> {
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await Navigator.of(context).push<bool>( final result = await Navigator.of(context).push<bool>(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const NotificationPreferencesPage( builder: (context) => const NotificationPreferencesPage(
loadFavoritesOnStart: true, loadFavoritesOnStart: true,
@ -234,7 +266,11 @@ class _OffersPageState extends State<OffersPage> {
.read<NotificationPreferencesBloc>() .read<NotificationPreferencesBloc>()
.add(ResetSubmissionStatus()); .add(ResetSubmissionStatus());
_loadPreferences(); await _loadPreferences();
if (result == true) {
_handleRefresh();
}
}, },
child: Row( child: Row(
children: [ children: [
@ -362,16 +398,21 @@ class _OffersPageState extends State<OffersPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
), ),
body: SingleChildScrollView( body: RefreshIndicator(
child: Column( onRefresh: _handleRefresh,
crossAxisAlignment: CrossAxisAlignment.start, child: SingleChildScrollView(
children: [ physics: const AlwaysScrollableScrollPhysics(),
_buildFavoriteCategoriesSection(), child: Column(
OffersView( crossAxisAlignment: CrossAxisAlignment.start,
isGpsEnabled: _isGpsEnabled, children: [
isConnectedToInternet: _isConnectedToInternet, _buildFavoriteCategoriesSection(),
), OffersView(
], isGpsEnabled: _isGpsEnabled,
isConnectedToInternet: _isConnectedToInternet,
selectedCategories: _selectedCategories,
),
],
),
), ),
), ),
), ),
@ -382,11 +423,13 @@ class _OffersPageState extends State<OffersPage> {
class OffersView extends StatelessWidget { class OffersView extends StatelessWidget {
final bool isGpsEnabled; final bool isGpsEnabled;
final bool isConnectedToInternet; final bool isConnectedToInternet;
final List<String> selectedCategories;
const OffersView({ const OffersView({
super.key, super.key,
required this.isGpsEnabled, required this.isGpsEnabled,
required this.isConnectedToInternet, required this.isConnectedToInternet,
required this.selectedCategories,
}); });
@override @override
@ -418,7 +461,13 @@ class OffersView extends StatelessWidget {
} }
if (state is OffersLoadSuccess) { if (state is OffersLoadSuccess) {
if (state.offers.isEmpty) { final filteredOffers = selectedCategories.isEmpty
? state.offers
: state.offers
.where((offer) => selectedCategories.contains(offer.category))
.toList();
if (filteredOffers.isEmpty) {
return const SizedBox( return const SizedBox(
height: 300, height: 300,
child: Center( child: Center(
@ -434,7 +483,7 @@ class OffersView extends StatelessWidget {
} }
final groupedOffers = groupBy( final groupedOffers = groupBy(
state.offers, filteredOffers,
(OfferModel offer) => offer.category, (OfferModel offer) => offer.category,
); );
final categories = groupedOffers.keys.toList(); final categories = groupedOffers.keys.toList();

View File

@ -30,13 +30,7 @@ void onStart(ServiceInstance service) async {
}); });
} }
mqttService.messages.listen((data) { Future<void> sendGpsData() async {
service.invoke('update', {'offers': data});
});
Timer.periodic(const Duration(seconds: 30), (timer) async {
debugPrint("✅ Background Service: Sending location...");
var locationStatus = await Permission.location.status; var locationStatus = await Permission.location.status;
if (!locationStatus.isGranted) { if (!locationStatus.isGranted) {
debugPrint("Background Service: Location permission not granted."); debugPrint("Background Service: Location permission not granted.");
@ -69,6 +63,20 @@ void onStart(ServiceInstance service) async {
} catch (e) { } catch (e) {
debugPrint("❌ Background Service Error: $e"); debugPrint("❌ Background Service Error: $e");
} }
}
mqttService.messages.listen((data) {
service.invoke('update', {'offers': data});
});
service.on('force_refresh').listen((event) async {
debugPrint("✅ Background Service: Received force_refresh event.");
await sendGpsData();
});
Timer.periodic(const Duration(seconds: 30), (timer) async {
debugPrint("✅ Background Service: Sending location via periodic timer...");
await sendGpsData();
}); });
} }