Compare commits

..

6 Commits

Author SHA1 Message Date
mohamadmahdi jebeli e4fc94a3e7 fixed mqtt bugs 2025-08-09 13:55:20 +03:30
mohamadmahdi jebeli 570ff6bb06 added notif offer page 2025-08-07 11:37:15 +03:30
mohamadmahdi jebeli 60c91e8fbb fix category and change comment ui 2025-08-04 14:44:50 +03:30
mohamadmahdi jebeli ce62c567c5 added comment 2025-08-04 12:15:17 +03:30
mohamadmahdi jebeli 608222e8a3 add refresh offer_page 2025-08-04 10:35:28 +03:30
mohamadmahdi jebeli f4cd446cde location background activity 2025-08-03 15:44:57 +03:30
32 changed files with 2017 additions and 371 deletions

View File

@ -11,32 +11,29 @@ plugins {
android {
namespace = "com.example.proxibuy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = "1.8"
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.proxibuy"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 23
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
@ -45,3 +42,8 @@ android {
flutter {
source = "../.."
}
dependencies {
// این نسخه به آخرین نسخه مورد نیاز آپدیت شد
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View File

@ -1,18 +1,20 @@
<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_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:label="Proxibuy"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<!-- android:networkSecurityConfig="@xml/network_security_config"> -->
<activity
android:name=".MainActivity"
android:exported="true"
@ -22,10 +24,7 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
@ -35,21 +34,20 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="location" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
</manifest>

View File

@ -5,6 +5,9 @@ class ApiConfig {
static const String updateUser = "/user/updateName";
static const String updateCategories = "/user/favoriteCategory";
static const String getFavoriteCategories = "/user/getfavoriteCategory";
static const String addReservation = "/reservation/add";
static const String getReservations = "/reservation/get";
static const String addReservation = "/reservation/add";
static const String getReservations = "/reservation/get";
static const String updateFcmToken = "/user/firebaseUpdate";
static const String addComment = "/comment/add";
static const String getComments = "/comment/get/";
}

View File

@ -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);
}

View File

@ -23,13 +23,18 @@ class CommentModel extends Equatable {
List<Object?> get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls];
factory CommentModel.fromJson(Map<String, dynamic> json) {
final List<String> images = (json['UserImages'] as List<dynamic>?)
?.map((image) => image['Url'] as String)
.toList() ??
[];
return CommentModel(
id: json['id'],
userName: json['userName'],
rating: (json['rating'] as num).toDouble(),
comment: json['comment'],
publishedAt: DateTime.parse(json['publishedAt']),
uploadedImageUrls: List<String>.from(json['uploadedImageUrls'] ?? []),
id: json['ID'] ?? '',
userName: json['User']?['Name'] ?? 'کاربر ناشناس',
rating: (json['Score'] as num?)?.toDouble() ?? 0.0,
comment: json['Text'] ?? '',
publishedAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(),
uploadedImageUrls: images,
);
}
}

View File

@ -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<String, dynamic> json) {
final bool statusValue = json['Status'] is bool ? json['Status'] : false;
return NotificationModel(
id: json['ID'] ?? '',
description: json['Description'] ?? 'No description available.',
createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(),
discountId: json['Discount']?['ID'] ?? '',
discountName: json['Discount']?['Name'] ?? 'Unknown Discount',
shopName: json['Discount']?['Shop']?['Name'] ?? 'Unknown Shop',
status: statusValue,
);
}
}

View File

@ -149,7 +149,7 @@ class OfferModel extends Equatable {
rating: 0.0,
ratingCount: 0,
comments: [],
discountInfo: json['Description'],
discountInfo: json['Description'] ?? 'توضیحات موجود نیست',
);
}
@ -157,7 +157,29 @@ class OfferModel extends Equatable {
imageUrls.isNotEmpty ? imageUrls.first : 'https://via.placeholder.com/400x200.png?text=No+Image';
@override
List<Object?> get props => [id];
List<Object?> get props => [
id,
storeName,
title,
discount,
imageUrls,
category,
distanceInMeters,
expiryTime,
address,
workingHours,
discountType,
isOpen,
rating,
ratingCount,
latitude,
longitude,
originalPrice,
finalPrice,
features,
discountInfo,
comments,
];
String get distanceAsString {
if (distanceInMeters < 1000) {

View File

@ -1,7 +1,6 @@
// lib/main.dart
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -10,18 +9,29 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:proxibuy/core/config/http_overrides.dart';
import 'package:proxibuy/firebase_options.dart';
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart';
import 'package:proxibuy/presentation/comment/bloc/comment_bloc.dart'; // این ایمپورت اضافه شد
import 'package:proxibuy/presentation/notification_preferences/bloc/notification_preferences_bloc.dart';
import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
import 'package:proxibuy/services/background_service.dart';
import 'package:proxibuy/services/mqtt_service.dart';
import 'core/config/app_colors.dart';
import 'package:proxibuy/presentation/pages/splash_screen.dart';
void main() async {
Future<String?> getFcmToken() async {
FirebaseMessaging messaging = FirebaseMessaging.instance;
String? token = await messaging.getToken();
print("🔥 Firebase Messaging Token: $token");
return token;
}
WidgetsFlutterBinding.ensureInitialized();
await initializeService();
HttpOverrides.global = MyHttpOverrides();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
Animate.restartOnHotReload = true;
runApp(const MyApp());
@ -34,22 +44,18 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
RepositoryProvider<MqttService>(
create: (context) => MqttService(),
),
RepositoryProvider<MqttService>(create: (context) => MqttService()),
BlocProvider<AuthBloc>(
create: (context) => AuthBloc()..add(CheckAuthStatusEvent()),
),
BlocProvider<ReservationCubit>(
create: (context) => ReservationCubit(),
),
BlocProvider<OffersBloc>(
create: (context) => OffersBloc(),
),
BlocProvider<ReservationCubit>(create: (context) => ReservationCubit()),
BlocProvider<OffersBloc>(create: (context) => OffersBloc()),
BlocProvider<NotificationPreferencesBloc>(
create: (context) => NotificationPreferencesBloc(),
),
BlocProvider<CommentBloc>(
create: (context) => CommentBloc(),
),
],
child: MaterialApp(
title: 'Proxibuy',

View File

@ -1,5 +1,3 @@
// lib/presentation/auth/bloc/auth_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
@ -15,7 +13,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
late final Dio _dio;
final _storage = const FlutterSecureStorage();
AuthBloc() : super(AuthInitial()) {
AuthBloc() : super(AuthUnknown()) {
_dio = Dio();
_dio.interceptors.add(
LogInterceptor(
@ -31,10 +29,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<VerifyOTPEvent>(_onVerifyOTP);
on<UpdateUserInfoEvent>(_onUpdateUserInfo);
on<LogoutEvent>(_onLogout);
on<SendFcmTokenEvent>(_onSendFcmToken);
}
Future<void> _onCheckAuthStatus(
CheckAuthStatusEvent event, Emitter<AuthState> emit) async {
CheckAuthStatusEvent event,
Emitter<AuthState> emit,
) async {
final token = await _storage.read(key: 'accessToken');
if (token != null && token.isNotEmpty) {
emit(AuthSuccess());
@ -43,6 +44,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
}
Future<void> _onSendOTP(SendOTPEvent event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
@ -52,10 +54,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
);
if (isClosed) return;
if (response.statusCode == 200) {
emit(AuthCodeSentSuccess(
phone: event.phoneNumber,
countryCode: event.countryCode,
));
emit(
AuthCodeSentSuccess(
phone: event.phoneNumber,
countryCode: event.countryCode,
),
);
} else {
emit(AuthFailure(response.data['message'] ?? 'خطایی رخ داد'));
}
@ -65,7 +69,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
}
Future<void> _onVerifyOTP(VerifyOTPEvent event, Emitter<AuthState> emit) async {
Future<void> _onVerifyOTP(
VerifyOTPEvent event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final response = await _dio.post(
@ -97,8 +104,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
Future<void> _onUpdateUserInfo(
UpdateUserInfoEvent event, Emitter<AuthState> emit) async {
debugPrint("AuthBloc: 🔵 ایونت UpdateUserInfoEvent دریافت شد با نام: ${event.name}");
UpdateUserInfoEvent event,
Emitter<AuthState> emit,
) async {
debugPrint(
"AuthBloc: 🔵 ایونت UpdateUserInfoEvent دریافت شد با نام: ${event.name}",
);
emit(AuthLoading());
try {
final token = await _storage.read(key: 'accessToken');
@ -115,30 +126,59 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
debugPrint("AuthBloc: 🟠 پاسخ سرور دریافت شد. StatusCode: ${response.statusCode}");
debugPrint(
"AuthBloc: 🟠 پاسخ سرور دریافت شد. StatusCode: ${response.statusCode}",
);
if (isClosed) {
debugPrint("AuthBloc: 🔴 خطا: BLoC قبل از اتمام عملیات بسته شده است.");
return;
debugPrint("AuthBloc: 🔴 خطا: BLoC قبل از اتمام عملیات بسته شده است.");
return;
}
if (response.statusCode == 200) {
debugPrint("AuthBloc: ✅ درخواست موفق بود. در حال emit کردن AuthSuccess...");
debugPrint(
"AuthBloc: ✅ درخواست موفق بود. در حال emit کردن AuthSuccess...",
);
emit(AuthSuccess());
} else {
debugPrint("AuthBloc: 🔴 سرور پاسخ ناموفق داد: ${response.data['message']}");
debugPrint(
"AuthBloc: 🔴 سرور پاسخ ناموفق داد: ${response.data['message']}",
);
emit(AuthFailure(response.data['message'] ?? 'خطا در ثبت اطلاعات'));
}
} on DioException catch (e) {
debugPrint("AuthBloc: 🔴 خطای DioException رخ داد: ${e.response?.data['message']}");
debugPrint(
"AuthBloc: 🔴 خطای DioException رخ داد: ${e.response?.data['message']}",
);
if (isClosed) return;
emit(AuthFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));
}
}
Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
await _storage.deleteAll();
emit(AuthInitial());
}
}
Future<void> _onSendFcmToken(
SendFcmTokenEvent event,
Emitter<AuthState> emit,
) async {
try {
final token = await _storage.read(key: 'accessToken');
if (token == null) {
emit(const AuthFailure("شما وارد نشده‌اید."));
return;
}
await _dio.post(
ApiConfig.baseUrl + ApiConfig.updateFcmToken,
data: {'Token': event.fcmToken},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
print("Firebase token: ${event.fcmToken}");
} on DioException catch (e) {
debugPrint("Error sending FCM token: ${e.response?.data['message']}");
}
}
}

View File

@ -25,4 +25,9 @@ class UpdateUserInfoEvent extends AuthEvent {
final String gender;
UpdateUserInfoEvent({required this.name, required this.gender});
}
class SendFcmTokenEvent extends AuthEvent {
final String fcmToken;
SendFcmTokenEvent({required this.fcmToken});
}

View File

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

View File

@ -0,0 +1,81 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:proxibuy/core/config/api_config.dart';
import 'package:proxibuy/presentation/comment/bloc/comment_event.dart';
import 'package:proxibuy/presentation/comment/bloc/comment_state.dart';
import 'package:http_parser/http_parser.dart';
import 'package:flutter/foundation.dart';
class CommentBloc extends Bloc<CommentEvent, CommentState> {
late final Dio _dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
CommentBloc() : super(CommentInitial()) {
_dio = Dio();
_dio.interceptors.add(
LogInterceptor(
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
error: true,
logPrint: (obj) => debugPrint(obj.toString()),
),
);
on<SubmitComment>(_onSubmitComment);
}
Future<void> _onSubmitComment(SubmitComment event, Emitter<CommentState> emit) async {
if (event.text.isEmpty && event.score == 0) {
emit(const CommentSubmissionFailure("لطفا امتیاز یا نظری برای این تخفیف ثبت کنید."));
return;
}
emit(CommentSubmitting());
try {
final token = await _storage.read(key: 'accessToken');
if (token == null) {
emit(const CommentSubmissionFailure("شما وارد نشده‌اید."));
return;
}
final formData = FormData.fromMap({
'Discount': event.discountId,
'Text': event.text,
'Score': event.score,
});
for (File imageFile in event.images) {
formData.files.add(MapEntry(
'Images',
await MultipartFile.fromFile(
imageFile.path,
filename: imageFile.path.split('/').last,
contentType: MediaType('image', 'jpeg'),
),
));
}
final response = await _dio.post(
ApiConfig.baseUrl + ApiConfig.addComment,
data: formData,
options: Options(
headers: {'Authorization': 'Bearer $token'},
),
);
if (response.statusCode == 200 || response.statusCode == 201) {
emit(CommentSubmissionSuccess());
} else {
emit(CommentSubmissionFailure(response.data['message'] ?? 'خطا در ارسال نظر'));
}
} on DioException catch (e) {
emit(CommentSubmissionFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));
} catch (e) {
emit(CommentSubmissionFailure('خطایی ناشناخته رخ داد: $e'));
}
}
}

View File

@ -0,0 +1,26 @@
import 'dart:io';
import 'package:equatable/equatable.dart';
abstract class CommentEvent extends Equatable {
const CommentEvent();
@override
List<Object> get props => [];
}
class SubmitComment extends CommentEvent {
final String discountId;
final String text;
final double score;
final List<File> images;
const SubmitComment({
required this.discountId,
required this.text,
required this.score,
required this.images,
});
@override
List<Object> get props => [discountId, text, score, images];
}

View File

@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
abstract class CommentState extends Equatable {
const CommentState();
@override
List<Object> get props => [];
}
class CommentInitial extends CommentState {}
class CommentSubmitting extends CommentState {}
class CommentSubmissionSuccess extends CommentState {}
class CommentSubmissionFailure extends CommentState {
final String error;
const CommentSubmissionFailure(this.error);
@override
List<Object> get props => [error];
}

View File

@ -26,12 +26,12 @@ class NotificationPreferencesBloc
LoadCategories event, Emitter<NotificationPreferencesState> emit) {
final categories = [
CategoryEntity(id: "e33dd7f9-5b20-4273-8eea-59da6ca5f206", name: 'لوازم دیجیتال', icon: Assets.icons.digital),
CategoryEntity(id: "b73a868a-a2d2-4d96-8fd4-615327ed9629", name: 'کافیشاپ', icon: Assets.icons.coffeeshop),
CategoryEntity(id: "b73a868a-a2d2-4d96-8fd4-615327ed9629", name: 'کافی شاپ', icon: Assets.icons.coffeeshop), // Change Here
CategoryEntity(id: "b5881239-bfd5-4c27-967a-187316a7e0b7", name: 'رستوران', icon: Assets.icons.resturan),
CategoryEntity(id: "6803b940-3e19-48cd-9190-28d9f25421ff", name: 'فستفود', icon: Assets.icons.fastfood),
CategoryEntity(id: "6803b940-3e19-48cd-9190-28d9f25421ff", name: 'فست فود', icon: Assets.icons.fastfood), // Change Here
CategoryEntity(id: "71e371f8-a47a-4a58-aee6-4ed0f26bf29b", name: 'پوشاک', icon: Assets.icons.pooshak),
CategoryEntity(id: "42acff41-1165-4e62-89b9-58db7329ec3a", name: 'تریا', icon: Assets.icons.teria),
CategoryEntity(id: "2f38918c-5566-4aec-a0a9-2c7c48b1e878", name: 'کیف‌وکفش', icon: Assets.icons.kafsh),
CategoryEntity(id: "2f38918c-5566-4aec-a0a9-2c7c48b1e878", name: 'کیف و کفش', icon: Assets.icons.kafsh), // Change Here
CategoryEntity(id: "52c51010-3a63-4264-a350-e011c889f3dd", name: 'سینما', icon: Assets.icons.cinama),
CategoryEntity(id: "34185954-f79f-4b9e-8eb2-1702679c40a0", name: 'لوازم آرایشی', icon: Assets.icons.arayesh),
CategoryEntity(id: "e4517b0c-aacf-4758-94bd-85f45062980f", name: 'طلا و زیورآلات', icon: Assets.icons.tala),
@ -126,6 +126,4 @@ class NotificationPreferencesBloc
ResetSubmissionStatus event, Emitter<NotificationPreferencesState> emit) {
emit(state.copyWith(submissionSuccess: false));
}
}
}

View File

@ -25,7 +25,6 @@ class AddPhotoScreen extends StatelessWidget {
required this.offer,
});
// متد ساخت توکن
Future<String> _generateQrToken(BuildContext context) async {
const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID');

View File

@ -0,0 +1,414 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_rating_bar/flutter_rating_bar.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/presentation/comment/bloc/comment_bloc.dart';
import 'package:proxibuy/presentation/comment/bloc/comment_event.dart';
import 'package:proxibuy/presentation/comment/bloc/comment_state.dart';
import 'package:proxibuy/presentation/pages/offers_page.dart';
import 'package:flutter_animate/flutter_animate.dart';
class CommentPage extends StatefulWidget {
final String discountId;
const CommentPage({super.key, required this.discountId});
@override
State<CommentPage> createState() => _CommentPageState();
}
class _CommentPageState extends State<CommentPage> {
final _commentController = TextEditingController();
double _rating = 0.0;
final List<File> _images = [];
final ImagePicker _picker = ImagePicker();
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
void _pickImage(ImageSource source) async {
if (_images.length >= 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('شما فقط می‌توانید ۲ عکس اضافه کنید.')),
);
return;
}
final pickedFile = await _picker.pickImage(
source: source,
imageQuality: 80,
);
if (pickedFile != null) {
setState(() {
_images.add(File(pickedFile.path));
});
}
}
void _removeImage(int index) {
setState(() {
_images.removeAt(index);
});
}
void _showImageSourceActionSheet() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
),
builder: (context) {
return SafeArea(
child: Wrap(
children: <Widget>[
ListTile(
leading: const Icon(
Icons.photo_library,
color: AppColors.primary,
),
title: const Text('انتخاب از گالری'),
onTap: () {
Navigator.of(context).pop();
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(Icons.camera_alt, color: AppColors.primary),
title: const Text('گرفتن عکس با دوربین'),
onTap: () {
Navigator.of(context).pop();
_pickImage(ImageSource.camera);
},
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CommentBloc(),
child: Scaffold(
backgroundColor: Colors.grey[50],
appBar: _buildCustomAppBar(),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 24),
Center(child: _buildRatingCard()),
const SizedBox(height: 16),
_buildCommentCard(),
const SizedBox(height: 16),
_buildImagePickerSection(),
const SizedBox(height: 32),
_buildActionButtons(),
]
.animate(interval: 100.ms)
.fadeIn(duration: 400.ms)
.slideY(begin: 0.2, curve: Curves.easeOut),
),
),
),
);
}
PreferredSizeWidget _buildCustomAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(70.0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(15),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text(
'ثبت نظر',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.normal,
fontSize: 18,
),
),
IconButton(
icon: SvgPicture.asset(Assets.icons.arrowLeft.path),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
),
),
);
}
Widget _buildHeader() {
return Center(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.confirm.withOpacity(0.1),
),
child: Icon(
Icons.check_circle_outline,
color: AppColors.confirm,
size: 50,
),
),
const SizedBox(height: 16),
const Text(
'خریدت با موفقیت انجام شد. منتظر دیدار دوباره‌ات هستیم. لطفا نظرت رو در مورد این تخفیف بهمون بگو.',
style: TextStyle(fontSize: 16, color: Colors.black, height: 1.5),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildRatingCard() {
return Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text(
"امتیاز شما چقدره؟",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
RatingBar.builder(
initialRating: 0,
minRating: 1,
direction: Axis.horizontal,
itemCount: 5,
itemPadding: const EdgeInsets.symmetric(horizontal: 7.0),
itemBuilder:
(context, _) =>
Icon(Icons.star, color: Colors.amber.shade700),
onRatingUpdate: (rating) => setState(() => _rating = rating),
),
],
),
),
);
}
Widget _buildCommentCard() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _commentController,
maxLines: 4,
textAlign: TextAlign.right,
decoration: InputDecoration(
labelText: "گوشمون به شماست",
hintText: "نظراتت رو بگو...",
alignLabelWithHint: true,
suffixIcon: Padding(padding: const EdgeInsets.all(12.0)),
),
),
);
}
Widget _buildImagePickerSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"افزودن عکس (اختیاری)",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
"می‌تونی عکس تجربه خریدت رو باهامون به اشتراک بذاری!",
style: TextStyle(fontSize: 14, color: AppColors.hint),
),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
...List.generate(_images.length, (index) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Image.file(
_images[index],
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () => _removeImage(index),
child: Container(
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
),
);
}),
if (_images.length < 2)
GestureDetector(
onTap: _showImageSourceActionSheet,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade400,
width: 1.5,
style: BorderStyle.solid,
),
),
child: const Icon(
Icons.add_a_photo_outlined,
color: Colors.grey,
size: 30,
),
),
),
],
),
),
],
);
}
Widget _buildActionButtons() {
return BlocConsumer<CommentBloc, CommentState>(
listener: (context, state) {
if (state is CommentSubmissionSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('نظر شما با موفقیت ثبت شد. ممنونیم!'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
),
);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const OffersPage()),
(route) => false,
);
} else if (state is CommentSubmissionFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
final isLoading = state is CommentSubmitting;
return Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
isLoading
? null
: () {
context.read<CommentBloc>().add(
SubmitComment(
discountId: widget.discountId,
text: _commentController.text,
score: _rating,
images: _images,
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.confirm,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
),
child:
isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(color: Colors.white),
)
: const Text('ارسال'),
),
),
const SizedBox(height: 12),
TextButton(
onPressed:
isLoading
? null
: () {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const OffersPage()),
(route) => false,
);
},
child: const Text(
'رد شدن',
style: TextStyle(color: Colors.black),
),
),
],
);
},
);
}
}

View File

@ -130,7 +130,7 @@ class _NotificationPreferencesPageState
const SizedBox(width: 8),
],
),
body: BlocListener<NotificationPreferencesBloc,
body: BlocConsumer<NotificationPreferencesBloc,
NotificationPreferencesState>(
listener: (context, state) async {
if (state.submissionSuccess) {
@ -157,7 +157,7 @@ class _NotificationPreferencesPageState
);
}
} else {
if (mounted) Navigator.of(context).pop();
if (mounted) Navigator.of(context).pop();
}
} catch (e) {
if (mounted) Navigator.of(context).pop();
@ -176,151 +176,151 @@ class _NotificationPreferencesPageState
);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
builder: (context, state) {
return Stack(
children: [
const Text(
'دریافت اعلان',
style: TextStyle(
fontFamily: 'Dana',
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
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),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'دریافت اعلان',
style: TextStyle(
fontFamily: 'Dana',
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
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),
Expanded(
child: BlocBuilder<NotificationPreferencesBloc,
NotificationPreferencesState>(
builder: (context, state) {
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.isLoading)
Container(
color: Colors.black.withOpacity(0.4),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
),
),
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

@ -23,6 +23,7 @@ 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/presentation/widgets/notification_panel.dart';
import 'package:proxibuy/services/mqtt_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -44,6 +45,11 @@ class _OffersPageState extends State<OffersPage> {
bool _isSubscribedToOffers = false;
bool _isGpsEnabled = false;
bool _isConnectedToInternet = true;
// Notifications panel state
final GlobalKey _bellKey = GlobalKey();
OverlayEntry? _notifOverlay;
bool _notifVisible = false;
int _notificationCount = 0;
@override
void initState() {
@ -52,6 +58,7 @@ class _OffersPageState extends State<OffersPage> {
_initializePage();
_initConnectivityListener();
_fetchInitialReservations();
_fetchNotificationCount();
}
@override
@ -60,6 +67,7 @@ class _OffersPageState extends State<OffersPage> {
_mqttMessageSubscription?.cancel();
_locationTimer?.cancel();
_connectivitySubscription?.cancel();
_removeNotificationOverlay();
super.dispose();
}
@ -227,8 +235,8 @@ class _OffersPageState extends State<OffersPage> {
final payload = {
"userID": userID,
"lat":32.6685,
"lng": 51.6826
"lat": position.latitude,
"lng": position.longitude
};
mqttService.publish("proxybuy/sendGps", payload);
@ -288,6 +296,139 @@ class _OffersPageState extends State<OffersPage> {
}
}
Future<void> _fetchNotificationCount() async {
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token == null) {
if (mounted) setState(() => _notificationCount = 0);
return;
}
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<dynamic> 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<double>(
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<void> _onRefresh() async {
await _sendLocationUpdate();
await _fetchNotificationCount();
await Future.delayed(const Duration(milliseconds: 300));
}
Widget _buildFavoriteCategoriesSection() {
return Padding(
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0),
@ -381,8 +522,51 @@ class _OffersPageState extends State<OffersPage> {
child: Assets.icons.logoWithName.svg(height: 40, width: 200),
),
actions: [
IconButton(
onPressed: () {}, icon: Assets.icons.notification.svg()),
// Notification bell with badge and overlay trigger
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
IconButton(
key: _bellKey,
onPressed: () {
if (_notifVisible) {
_hideNotificationOverlay();
} else {
_fetchNotificationCount();
_showNotificationOverlay();
}
},
icon: Assets.icons.notification.svg(),
),
if (_notificationCount > 0)
Positioned(
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,
),
),
),
),
],
),
BlocBuilder<ReservationCubit, ReservationState>(
builder: (context, state) {
final reservedCount = state.reservedProductIds.length;
@ -445,16 +629,21 @@ class _OffersPageState extends State<OffersPage> {
const SizedBox(width: 8),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFavoriteCategoriesSection(),
OffersView(
isGpsEnabled: _isGpsEnabled,
isConnectedToInternet: _isConnectedToInternet,
),
],
body: RefreshIndicator(
onRefresh: _onRefresh,
color: AppColors.active,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFavoriteCategoriesSection(),
OffersView(
isGpsEnabled: _isGpsEnabled,
isConnectedToInternet: _isConnectedToInternet,
),
],
),
),
),
),

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:dio/dio.dart';
@ -11,6 +12,7 @@ import 'package:maps_launcher/maps_launcher.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/comment_model.dart';
import 'package:proxibuy/data/models/offer_model.dart';
import 'package:proxibuy/presentation/pages/add_photo_screen.dart';
import 'package:proxibuy/presentation/pages/reservation_details_screen.dart';
@ -87,20 +89,9 @@ class ProductDetailPage extends StatelessWidget {
headers: {'Authorization': 'Bearer $token'},
);
debugPrint("----------- REQUEST-----------");
debugPrint("URL: POST $url");
debugPrint("Headers: ${options.headers}");
debugPrint("Body: $data");
debugPrint("-----------------------------");
final response =
await dio.post(url, data: data, options: options);
debugPrint("---------- RESPONSE-----------");
debugPrint("StatusCode: ${response.statusCode}");
debugPrint("Data: ${response.data}");
debugPrint("-----------------------------");
if (context.mounted) Navigator.of(context).pop();
if (response.statusCode == 200) {
@ -131,11 +122,6 @@ class ProductDetailPage extends StatelessWidget {
} on DioException catch (e) {
if (context.mounted) Navigator.of(context).pop();
debugPrint("---------- ERROR-----------");
debugPrint("StatusCode: ${e.response?.statusCode}");
debugPrint("Data: ${e.response?.data}");
debugPrint("--------------------------");
final errorMessage = e.response?.data?['message'] ??
'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.';
ScaffoldMessenger.of(context).showSnackBar(
@ -145,10 +131,6 @@ class ProductDetailPage extends StatelessWidget {
);
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
debugPrint("---------- GENERAL ERROR -----------");
debugPrint(e.toString());
debugPrint("------------------------------------");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
@ -205,12 +187,47 @@ class _ProductDetailViewState extends State<ProductDetailView> {
late List<String> imageList;
late String selectedImage;
final String _uploadKey = 'upload_image';
late Future<List<CommentModel>> _commentsFuture;
@override
void initState() {
super.initState();
imageList = List.from(widget.offer.imageUrls)..add(_uploadKey);
selectedImage = imageList.first;
selectedImage = imageList.isNotEmpty ? imageList.first : 'https://via.placeholder.com/400x200.png?text=No+Image';
_commentsFuture = _fetchComments();
}
Future<List<CommentModel>> _fetchComments() async {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token == null) {
throw Exception('Authentication token not found!');
}
try {
final dio = Dio();
final response = await dio.get(
ApiConfig.baseUrl + ApiConfig.getComments + widget.offer.id,
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (response.statusCode == 200) {
final List<dynamic> 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) {
debugPrint("DioException fetching comments: $e");
if (e.response != null) {
debugPrint("Response data: ${e.response?.data}");
}
throw Exception('Failed to load comments: ${e.message}');
} catch (e) {
debugPrint("Error fetching comments: $e");
throw Exception('An unknown error occurred: $e');
}
}
void _launchMaps(double lat, double lon, String title) {
@ -384,19 +401,6 @@ class _ProductDetailViewState extends State<ProductDetailView> {
),
);
},
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),
),
),
);
}
@ -405,8 +409,7 @@ class _ProductDetailViewState extends State<ProductDetailView> {
? widget.offer.expiryTime.difference(DateTime.now())
: Duration.zero;
final formatCurrency =
NumberFormat.decimalPattern('fa_IR'); // Or 'en_US'
final formatCurrency = NumberFormat.decimalPattern('fa_IR');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0)
@ -663,7 +666,7 @@ class _ProductDetailViewState extends State<ProductDetailView> {
slideDirection: SlideDirection.up,
separator: ':',
style: const TextStyle(
fontSize: 50,
fontSize: 45,
fontWeight: FontWeight.bold,
color: AppColors.countdown,
),
@ -687,7 +690,21 @@ class _ProductDetailViewState extends State<ProductDetailView> {
const SizedBox(height: 24),
_buildDiscountTypeSection(),
const SizedBox(height: 24),
CommentsSection(comments: widget.offer.comments),
FutureBuilder<List<CommentModel>>(
future: _commentsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('خطا در بارگذاری نظرات. لطفاً صفحه را رفرش کنید.'));
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return SizedBox();
}
return CommentsSection(comments: snapshot.data!);
},
),
].animate(interval: 80.ms).slideX(begin: -0.05).fadeIn(
duration: 400.ms,
curve: Curves.easeOut,

View File

@ -1,12 +1,15 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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: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/pages/comment_page.dart';
import 'package:proxibuy/services/mqtt_service.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:flutter_animate/flutter_animate.dart';
@ -30,17 +33,17 @@ class _ReservationConfirmationPageState
Timer? _timer;
Duration _remaining = Duration.zero;
final AudioPlayer _audioPlayer = AudioPlayer();
StreamSubscription? _mqttSubscription;
@override
void initState() {
super.initState();
_playSound();
_calculateRemainingTime();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_calculateRemainingTime();
});
_listenToMqtt();
}
void _playSound() async {
@ -69,14 +72,46 @@ class _ReservationConfirmationPageState
}
}
void _listenToMqtt() async {
final mqttService = context.read<MqttService>();
const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID');
final discountId = widget.offer.id;
if (userID == null) {
debugPrint("MQTT Listener: UserID not found, cannot subscribe.");
return;
}
final topic = 'user-order/$userID/$discountId';
mqttService.subscribe(topic);
debugPrint("✅ Subscribed to MQTT topic: $topic");
_mqttSubscription = mqttService.messages.listen((message) {
debugPrint("✅ MQTT Message received on details page: $message");
final receivedDiscountId = message['Discount'];
if (receivedDiscountId == discountId) {
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => CommentPage(discountId: discountId),
),
);
});
}
}
});
}
@override
void dispose() {
_timer?.cancel();
_audioPlayer.dispose();
_mqttSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Directionality(
@ -94,13 +129,13 @@ class _ReservationConfirmationPageState
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'تخفیف ${widget.offer.discountType} رزرو شد!',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
)
'تخفیف ${widget.offer.discountType} رزرو شد!',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
)
.animate()
.fadeIn(delay: 300.ms, duration: 500.ms)
.slideY(begin: -0.2, end: 0),
@ -227,7 +262,7 @@ class _ReservationConfirmationPageState
SvgPicture.asset(Assets.icons.ticketDiscount.path),
const SizedBox(width: 6),
Text(
'(${(100-widget.offer.finalPrice/widget.offer.originalPrice*100).toInt()}%)',
'(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)',
style: const TextStyle(
fontSize: 16,
color: AppColors.singleOfferType,
@ -401,4 +436,4 @@ class _ReservationConfirmationPageState
),
);
}
}
}

View File

@ -3,12 +3,14 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart';
import 'package:proxibuy/presentation/pages/onboarding_page.dart';
import 'package:proxibuy/presentation/pages/offers_page.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/services/mqtt_service.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@ -27,10 +29,25 @@ class _SplashScreenState extends State<SplashScreen> {
super.initState();
Future.delayed(const Duration(seconds: 2), _startProcess);
}
Future<String?> getFcmToken() async {
FirebaseMessaging messaging = FirebaseMessaging.instance;
try {
String? token = await messaging.getToken();
debugPrint("🔥 Firebase Messaging Token: $token");
return token;
} catch(e) {
debugPrint("Error getting FCM token: $e");
return null;
}
}
Future<void> _startProcess() async {
if (!mounted) return;
await _requestPermissions();
final hasInternet = await _checkInternet();
if (!hasInternet) {
setState(() {
@ -59,12 +76,19 @@ class _SplashScreenState extends State<SplashScreen> {
});
return;
}
final String? fcmToken = await getFcmToken();
final mqttService = context.read<MqttService>();
final storage = const FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token != null && token.isNotEmpty) {
if (fcmToken != null) {
context.read<AuthBloc>().add(SendFcmTokenEvent(fcmToken: fcmToken));
}
if (mqttService.isConnected) {
_navigateToOffers();
return;
@ -186,4 +210,13 @@ class _SplashScreenState extends State<SplashScreen> {
),
);
}
Future<void> _requestPermissions() async {
await Permission.notification.request();
var status = await Permission.location.request();
if (status.isGranted) {
await Permission.locationAlways.request();
}
}
}

View File

@ -0,0 +1,262 @@
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/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;
final VoidCallback? onListChanged;
const NotificationPanel({super.key, required this.onClose, this.onListChanged});
@override
State<NotificationPanel> createState() => _NotificationPanelState();
}
class _NotificationPanelState extends State<NotificationPanel> {
List<NotificationModel> _notifications = [];
bool _isLoading = true;
String _errorMessage = '';
final Dio _dio = Dio();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
@override
void initState() {
super.initState();
_fetchNotifications();
}
Future<void> _fetchNotifications() async {
final token = await _storage.read(key: 'accessToken');
if (token == null) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = 'برای مشاهده اعلان‌ها، لطفا ابتدا وارد شوید.';
});
widget.onListChanged?.call();
}
return;
}
try {
final response = await _dio.get(
'https://proxybuy.liara.run/notify/get',
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (response.statusCode == 200 && mounted) {
final List<dynamic> data = response.data['data'] ?? [];
setState(() {
_notifications = data.map((json) => NotificationModel.fromJson(json)).toList();
_isLoading = false;
});
widget.onListChanged?.call();
} else if (response.statusCode == 201) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = 'اعلانی وجود ندارد';
});
widget.onListChanged?.call();
}
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_errorMessage = 'اتصال به سرور برقرار نشد.';
});
widget.onListChanged?.call();
}
}
}
Future<void> _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(
'https://proxybuy.liara.run/notify/ignore/$notificationId',
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (response.statusCode == 200 && mounted) {
setState(() {
_notifications.removeWhere((n) => n.id == notificationId);
});
widget.onListChanged?.call();
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(response.data?['message'] ?? 'خطا در حذف اعلان.')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('خطا در ارتباط با سرور.')),
);
debugPrint('Error ignoring notification: $e');
}
}
}
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<OffersBloc>().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('بزن بریم'),
),
],
),
],
),
],
),
);
}
}

View File

@ -1,11 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:intl/intl.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/presentation/pages/comment_page.dart';
import 'package:proxibuy/services/mqtt_service.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/data/models/offer_model.dart';
@ -25,6 +29,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
Timer? _timer;
Duration _remaining = Duration.zero;
Future<String>? _qrTokenFuture;
StreamSubscription? _mqttSubscription; // برای مدیریت لیسنر MQTT
@override
void initState() {
@ -57,10 +62,52 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
}
void _toggleExpansion() {
final isExpired = _remaining <= Duration.zero;
if (isExpired) return;
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded && _qrTokenFuture == null) {
_qrTokenFuture = _generateQrToken();
if (_isExpanded) {
if (_qrTokenFuture == null) {
_qrTokenFuture = _generateQrToken();
}
_listenToMqtt();
} else {
_mqttSubscription?.cancel();
}
});
}
void _listenToMqtt() async {
final mqttService = context.read<MqttService>();
const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID');
final discountId = widget.offer.id;
if (userID == null) {
debugPrint("MQTT Listener: UserID not found, cannot subscribe.");
return;
}
final topic = 'user-order/$userID/$discountId';
mqttService.subscribe(topic);
debugPrint("✅ Subscribed to MQTT topic: $topic");
_mqttSubscription = mqttService.messages.listen((message) {
debugPrint("✅ MQTT Message received on reserved card: $message");
final receivedDiscountId = message['Discount'];
if (receivedDiscountId == discountId) {
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => CommentPage(discountId: discountId),
),
);
});
}
}
});
}
@ -84,6 +131,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
@override
void dispose() {
_timer?.cancel();
_mqttSubscription?.cancel();
super.dispose();
}
@ -104,24 +152,47 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
margin: EdgeInsets.zero,
child: _buildOfferPrimaryDetails(),
),
_buildActionsRow(),
_buildExpansionPanel(),
_buildActionsRow(isExpired),
if (!isExpired) _buildExpansionPanel(),
],
);
if (isExpired) {
return ColorFiltered(
colorFilter: const ColorFilter.matrix(<double>[
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
]),
child: cardContent,
);
}
return cardContent;
return Stack(
children: [
if (isExpired)
ColorFiltered(
colorFilter: const ColorFilter.matrix(<double>[
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
]),
child: cardContent,
)
else
cardContent,
if (isExpired)
Positioned(
top: 12,
left: -35,
child: Transform.rotate(
angle: -45 * (3.1415926535 / 180),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 4),
color: Colors.red,
child: const Text(
'منقضی شده',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
),
],
);
}
Widget _buildOfferPrimaryDetails() {
@ -195,13 +266,13 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
);
}
Widget _buildActionsRow() {
Widget _buildActionsRow(bool isExpired) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (_remaining > Duration.zero)
if (!isExpired)
Column(
children: [
Localizations.override(
@ -220,7 +291,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
fontSize: 20,
color: AppColors.countdown,
),
decoration: const BoxDecoration(color: Colors.white),
decoration: const BoxDecoration(color: Colors.transparent),
shouldShowDays: (d) => d.inDays > 0,
shouldShowHours: (d) => d.inHours > 0,
shouldShowMinutes: (d) => d.inSeconds > 0,
@ -231,36 +302,30 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
],
)
else
const Text(
'منقضی شده',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 16,
const SizedBox(height: 0),
SizedBox(width: 10),
if (!isExpired)
TextButton(
onPressed: _toggleExpansion,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_isExpanded ? 'بستن' : 'اطلاعات بیشتر',
style: TextStyle(color: AppColors.active),
),
const SizedBox(width: 12),
AnimatedRotation(
turns: _isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 300),
child: SvgPicture.asset(
Assets.icons.arrowDown.path,
height: 20,
),
),
],
),
),
SizedBox(width: 10),
TextButton(
onPressed: _toggleExpansion,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_isExpanded ? 'بستن' : 'اطلاعات بیشتر',
style: TextStyle(color: AppColors.active),
),
const SizedBox(width: 12),
AnimatedRotation(
turns: _isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 300),
child: SvgPicture.asset(
Assets.icons.arrowDown.path,
height: 20,
),
),
],
),
),
],
),
);
@ -324,6 +389,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
Widget _buildExpansionPanel() {
final formatCurrency = NumberFormat.decimalPattern('fa_IR');
final isExpired = _remaining <= Duration.zero;
return AnimatedCrossFade(
firstChild: Container(),
@ -339,8 +405,8 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 9,
horizontal: 15,
vertical: 12,
),
decoration: BoxDecoration(
color: AppColors.singleOfferType,
@ -351,7 +417,8 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.normal,
fontSize: 17,
fontSize: 13,
overflow: TextOverflow.ellipsis
),
),
),
@ -365,7 +432,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
Text(
'(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)',
style: const TextStyle(
fontSize: 14,
fontSize: 12,
color: AppColors.singleOfferType,
fontWeight: FontWeight.normal,
),
@ -374,7 +441,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
Text(
formatCurrency.format(widget.offer.originalPrice),
style: TextStyle(
fontSize: 14,
fontSize: 13,
color: Colors.grey.shade600,
decoration: TextDecoration.lineThrough,
),
@ -386,7 +453,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
'${formatCurrency.format(widget.offer.finalPrice)} تومان',
style: const TextStyle(
color: AppColors.singleOfferType,
fontSize: 18,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
@ -394,39 +461,41 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
),
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Color.fromARGB(255, 246, 246, 246),
borderRadius: BorderRadius.circular(16),
if (!isExpired) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Color.fromARGB(255, 246, 246, 246),
borderRadius: BorderRadius.circular(16),
),
child: FutureBuilder<String>(
future: _qrTokenFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(
height: 280.0,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return const SizedBox(
height: 280.0,
child: Center(child: Text("خطا در ساخت کد QR")),
);
}
if (snapshot.hasData) {
return QrImageView(
data: snapshot.data!,
version: QrVersions.auto,
size: 280.0,
);
}
return const SizedBox.shrink();
},
),
),
child: FutureBuilder<String>(
future: _qrTokenFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(
height: 280.0,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return const SizedBox(
height: 280.0,
child: Center(child: Text("خطا در ساخت کد QR")),
);
}
if (snapshot.hasData) {
return QrImageView(
data: snapshot.data!,
version: QrVersions.auto,
size: 280.0,
);
}
return const SizedBox.shrink();
},
),
),
],
],
),
),

View File

@ -31,7 +31,7 @@ class CustomStarRating extends StatelessWidget {
stars.add(_buildStar(Assets.icons.star2.path));
remaining = 0;
} else {
stars.add(_buildStar(Assets.icons.starHalf.path,));
stars.add(_buildStar(Assets.icons.star2.path,));
}
}
return Row(mainAxisSize: MainAxisSize.min, children: stars);

View File

@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_background_service_android/flutter_background_service_android.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:proxibuy/services/mqtt_service.dart';
const notificationChannelId = 'proxibuy_foreground_service';
const notificationId = 888;
@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
final MqttService mqttService = MqttService();
const storage = FlutterSecureStorage();
if (service is AndroidServiceInstance) {
service.setForegroundNotificationInfo(
title: "ProxiBuy فعال است",
content: "در حال جستجو برای تخفیف های اطراف شما...",
);
service.on('stopService').listen((event) {
service.stopSelf();
});
}
Future<void> sendGpsData() async {
var locationStatus = await Permission.location.status;
if (!locationStatus.isGranted) {
debugPrint("Background Service: Location permission not granted.");
return;
}
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
final token = await storage.read(key: 'accessToken');
final userID = await storage.read(key: 'userID');
if (token != null && userID != null) {
if (!mqttService.isConnected) {
await mqttService.connect(token);
final topic = 'user-proxybuy/$userID';
mqttService.subscribe(topic);
}
if (mqttService.isConnected) {
final payload = {
"userID": userID,
"lat": position.latitude,
"lng": position.longitude
};
mqttService.publish("proxybuy/sendGps", payload);
debugPrint("Background Service: GPS sent successfully.");
}
}
} catch (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();
});
}
Future<void> initializeService() async {
final service = FlutterBackgroundService();
const AndroidNotificationChannel channel = AndroidNotificationChannel(
notificationChannelId,
'ProxiBuy Background Service',
description: 'This channel is used for location service notifications.',
importance: Importance.low,
);
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: onStart,
isForegroundMode: true,
autoStart: true,
notificationChannelId: notificationChannelId,
initialNotificationTitle: 'ProxiBuy فعال است',
initialNotificationContent: 'در حال جستجو برای تخفیف‌های اطراف شما...',
foregroundServiceNotificationId: notificationId,
),
iosConfiguration: IosConfiguration(
autoStart: true,
onForeground: onStart,
),
);
}

View File

@ -0,0 +1,100 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_background_service_android/flutter_background_service_android.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:geolocator/geolocator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:proxibuy/services/mqtt_service.dart';
const notificationChannelId = 'proxibuy_foreground_service';
const notificationId = 888;
@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
final MqttService mqttService = MqttService();
const storage = FlutterSecureStorage();
if (service is AndroidServiceInstance) {
service.setForegroundNotificationInfo(
title: "ProxiBuy فعال است",
content: "در حال یافتن تخفیف های اطراف شما...",
);
service.on('setAsForeground').listen((event) {
service.setAsForegroundService();
});
service.on('setAsBackground').listen((event) {
service.setAsBackgroundService();
});
}
service.on('stopService').listen((event) {
service.stopSelf();
});
Timer.periodic(const Duration(seconds: 30), (timer) async {
debugPrint("✅ Background Service is running and sending location...");
var locationStatus = await Permission.location.status;
var locationAlwaysStatus = await Permission.locationAlways.status;
if (!locationStatus.isGranted || !locationAlwaysStatus.isGranted) {
debugPrint("Background Service: Permissions not granted. Task skipped.");
return;
}
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
final token = await storage.read(key: 'accessToken');
final userID = await storage.read(key: 'userID');
if (token != null && token.isNotEmpty && userID != null) {
if (!mqttService.isConnected) {
await mqttService.connect(token);
}
if (mqttService.isConnected) {
final payload = {"userID": userID, "lat": position.latitude, "lng": position.longitude};
mqttService.publish("proxybuy/sendGps", payload);
debugPrint("Background Service: GPS sent successfully.");
}
}
});
}
Future<void> initializeService() async {
final service = FlutterBackgroundService();
const AndroidNotificationChannel channel = AndroidNotificationChannel(
notificationChannelId,
'ProxiBuy Background Service',
description: 'This channel is used for location service notifications.',
importance: Importance.low,
);
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: onStart,
isForegroundMode: true,
autoStart: true,
notificationChannelId: notificationChannelId,
initialNotificationTitle: 'ProxiBuy فعال است',
initialNotificationContent: 'در حال جستجو برای تخفیف‌های اطراف شما...',
foregroundServiceNotificationId: notificationId,
),
iosConfiguration: IosConfiguration(
autoStart: true,
onForeground: onStart,
),
);
}

View File

@ -3,12 +3,13 @@ import 'dart:convert';
import 'dart:io';
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';
class MqttService {
MqttServerClient? client;
final String server = '5.75.197.180';
final String server = '62.60.214.99';
final int port = 1883;
final StreamController<Map<String, dynamic>> _messageStreamController =
StreamController.broadcast();
@ -20,7 +21,9 @@ class MqttService {
}
Future<void> connect(String token) async {
final String clientId =
const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID');
final String clientId = userID??
'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0');
final String username = 'ignored';
final String password = token;

View File

@ -11,7 +11,10 @@ import connectivity_plus
import file_selector_macos
import firebase_auth
import firebase_core
import firebase_crashlytics
import firebase_messaging
import firebase_storage
import flutter_local_notifications
import flutter_localization
import flutter_secure_storage_macos
import geolocator_apple
@ -28,7 +31,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))

View File

@ -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:
@ -521,6 +529,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.24.1"
firebase_crashlytics:
dependency: "direct main"
description:
name: firebase_crashlytics
sha256: "662ae6443da91bca1fb0be8aeeac026fa2975e8b7ddfca36e4d90ebafa35dde1"
url: "https://pub.dev"
source: hosted
version: "4.3.10"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
sha256: "7222a8a40077c79f6b8b3f3439241c9f2b34e9ddfde8381ffc512f7b2e61f7eb"
url: "https://pub.dev"
source: hosted
version: "3.8.10"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
url: "https://pub.dev"
source: hosted
version: "15.2.10"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
url: "https://pub.dev"
source: hosted
version: "4.6.10"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
url: "https://pub.dev"
source: hosted
version: "3.10.10"
firebase_storage:
dependency: "direct main"
description:
@ -566,6 +614,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_background_service:
dependency: "direct main"
description:
name: flutter_background_service
sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d"
url: "https://pub.dev"
source: hosted
version: "5.1.0"
flutter_background_service_android:
dependency: "direct main"
description:
name: flutter_background_service_android
sha256: ca0793d4cd19f1e194a130918401a3d0b1076c81236f7273458ae96987944a87
url: "https://pub.dev"
source: hosted
version: "6.3.1"
flutter_background_service_ios:
dependency: transitive
description:
name: flutter_background_service_ios
sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba"
url: "https://pub.dev"
source: hosted
version: "5.0.3"
flutter_background_service_platform_interface:
dependency: transitive
description:
name: flutter_background_service_platform_interface
sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41
url: "https://pub.dev"
source: hosted
version: "5.1.2"
flutter_bloc:
dependency: "direct main"
description:
@ -614,6 +694,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "20ca0a9c82ce0c855ac62a2e580ab867f3fbea82680a90647f7953832d0850ae"
url: "https://pub.dev"
source: hosted
version: "19.4.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
url: "https://pub.dev"
source: hosted
version: "9.1.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98
url: "https://pub.dev"
source: hosted
version: "1.0.2"
flutter_localization:
dependency: "direct main"
description:
@ -635,6 +747,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.28"
flutter_rating_bar:
dependency: "direct main"
description:
name: flutter_rating_bar
sha256: d2af03469eac832c591a1eba47c91ecc871fe5708e69967073c043b2d775ed93
url: "https://pub.dev"
source: hosted
version: "4.0.1"
flutter_secure_storage:
dependency: "direct main"
description:
@ -1121,10 +1241,10 @@ packages:
dependency: "direct main"
description:
name: permission_handler
sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f"
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.0+1"
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
@ -1333,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
@ -1466,6 +1594,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.5"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
timing:
dependency: transitive
description:
@ -1642,6 +1778,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.13.0"
workmanager:
dependency: "direct main"
description:
name: workmanager
sha256: "746a50c535af15b6dc225abbd9b52ab272bcd292c535a104c54b5bc02609c38a"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
xdg_directories:
dependency: transitive
description:

View File

@ -42,7 +42,7 @@ dependencies:
flutter_gen: ^5.10.0
country_picker: ^2.0.27
geolocator: ^14.0.1
permission_handler: ^12.0.0+1
permission_handler: ^12.0.1
cached_network_image: ^3.4.1
collection: ^1.19.1
shared_preferences: ^2.5.3
@ -62,6 +62,15 @@ dependencies:
dart_jsonwebtoken: ^3.2.0
audioplayers: ^6.5.0
intl: ^0.19.0
flutter_background_service: ^5.1.0
flutter_background_service_android: ^6.3.1
flutter_local_notifications: ^19.4.0
workmanager: ^0.7.0
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:

View File

@ -19,6 +19,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)