background-activity #1
|
|
@ -11,32 +11,29 @@ 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 {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
isCoreLibraryDesugaringEnabled = true
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.example.proxibuy"
|
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
|
minSdk = 23
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
multiDexEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
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")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,3 +42,8 @@ android {
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// این نسخه به آخرین نسخه مورد نیاز آپدیت شد
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
<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_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" />
|
||||||
<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_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_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
|
<application
|
||||||
android:label="Proxibuy"
|
android:label="Proxibuy"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<!-- android:networkSecurityConfig="@xml/network_security_config"> -->
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
@ -22,10 +24,7 @@
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
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
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
|
|
@ -35,21 +34,20 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -5,6 +5,9 @@ class ApiConfig {
|
||||||
static const String updateUser = "/user/updateName";
|
static const String updateUser = "/user/updateName";
|
||||||
static const String updateCategories = "/user/favoriteCategory";
|
static const String updateCategories = "/user/favoriteCategory";
|
||||||
static const String getFavoriteCategories = "/user/getfavoriteCategory";
|
static const String getFavoriteCategories = "/user/getfavoriteCategory";
|
||||||
static const String addReservation = "/reservation/add";
|
static const String addReservation = "/reservation/add";
|
||||||
static const String getReservations = "/reservation/get";
|
static const String getReservations = "/reservation/get";
|
||||||
|
static const String updateFcmToken = "/user/firebaseUpdate";
|
||||||
|
static const String addComment = "/comment/add";
|
||||||
|
static const String getComments = "/comment/get/";
|
||||||
}
|
}
|
||||||
|
|
@ -17,4 +17,6 @@ class AppColors {
|
||||||
static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251);
|
static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251);
|
||||||
static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28);
|
static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28);
|
||||||
static const Color uploadElevated = Color.fromARGB(255, 233, 245, 254);
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -23,13 +23,18 @@ class CommentModel extends Equatable {
|
||||||
List<Object?> get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls];
|
List<Object?> get props => [id, userName, rating, comment, publishedAt, uploadedImageUrls];
|
||||||
|
|
||||||
factory CommentModel.fromJson(Map<String, dynamic> json) {
|
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(
|
return CommentModel(
|
||||||
id: json['id'],
|
id: json['ID'] ?? '',
|
||||||
userName: json['userName'],
|
userName: json['User']?['Name'] ?? 'کاربر ناشناس',
|
||||||
rating: (json['rating'] as num).toDouble(),
|
rating: (json['Score'] as num?)?.toDouble() ?? 0.0,
|
||||||
comment: json['comment'],
|
comment: json['Text'] ?? '',
|
||||||
publishedAt: DateTime.parse(json['publishedAt']),
|
publishedAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(),
|
||||||
uploadedImageUrls: List<String>.from(json['uploadedImageUrls'] ?? []),
|
uploadedImageUrls: images,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -149,7 +149,7 @@ class OfferModel extends Equatable {
|
||||||
rating: 0.0,
|
rating: 0.0,
|
||||||
ratingCount: 0,
|
ratingCount: 0,
|
||||||
comments: [],
|
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';
|
imageUrls.isNotEmpty ? imageUrls.first : 'https://via.placeholder.com/400x200.png?text=No+Image';
|
||||||
|
|
||||||
@override
|
@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 {
|
String get distanceAsString {
|
||||||
if (distanceInMeters < 1000) {
|
if (distanceInMeters < 1000) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// lib/main.dart
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/core/config/http_overrides.dart';
|
||||||
import 'package:proxibuy/firebase_options.dart';
|
import 'package:proxibuy/firebase_options.dart';
|
||||||
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.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/notification_preferences/bloc/notification_preferences_bloc.dart';
|
||||||
import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart';
|
import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart';
|
||||||
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.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 'package:proxibuy/services/mqtt_service.dart';
|
||||||
import 'core/config/app_colors.dart';
|
import 'core/config/app_colors.dart';
|
||||||
import 'package:proxibuy/presentation/pages/splash_screen.dart';
|
import 'package:proxibuy/presentation/pages/splash_screen.dart';
|
||||||
|
|
||||||
|
|
||||||
void main() async {
|
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();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
await initializeService();
|
||||||
|
|
||||||
HttpOverrides.global = MyHttpOverrides();
|
HttpOverrides.global = MyHttpOverrides();
|
||||||
|
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
Animate.restartOnHotReload = true;
|
Animate.restartOnHotReload = true;
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
|
|
@ -34,22 +44,18 @@ class MyApp extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
RepositoryProvider<MqttService>(
|
RepositoryProvider<MqttService>(create: (context) => MqttService()),
|
||||||
create: (context) => MqttService(),
|
|
||||||
),
|
|
||||||
BlocProvider<AuthBloc>(
|
BlocProvider<AuthBloc>(
|
||||||
create: (context) => AuthBloc()..add(CheckAuthStatusEvent()),
|
create: (context) => AuthBloc()..add(CheckAuthStatusEvent()),
|
||||||
),
|
),
|
||||||
BlocProvider<ReservationCubit>(
|
BlocProvider<ReservationCubit>(create: (context) => ReservationCubit()),
|
||||||
create: (context) => ReservationCubit(),
|
BlocProvider<OffersBloc>(create: (context) => OffersBloc()),
|
||||||
),
|
|
||||||
BlocProvider<OffersBloc>(
|
|
||||||
create: (context) => OffersBloc(),
|
|
||||||
),
|
|
||||||
BlocProvider<NotificationPreferencesBloc>(
|
BlocProvider<NotificationPreferencesBloc>(
|
||||||
create: (context) => NotificationPreferencesBloc(),
|
create: (context) => NotificationPreferencesBloc(),
|
||||||
),
|
),
|
||||||
|
BlocProvider<CommentBloc>(
|
||||||
|
create: (context) => CommentBloc(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'Proxibuy',
|
title: 'Proxibuy',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// lib/presentation/auth/bloc/auth_bloc.dart
|
|
||||||
|
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
@ -15,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(
|
||||||
|
|
@ -31,10 +29,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
on<VerifyOTPEvent>(_onVerifyOTP);
|
on<VerifyOTPEvent>(_onVerifyOTP);
|
||||||
on<UpdateUserInfoEvent>(_onUpdateUserInfo);
|
on<UpdateUserInfoEvent>(_onUpdateUserInfo);
|
||||||
on<LogoutEvent>(_onLogout);
|
on<LogoutEvent>(_onLogout);
|
||||||
|
on<SendFcmTokenEvent>(_onSendFcmToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onCheckAuthStatus(
|
Future<void> _onCheckAuthStatus(
|
||||||
CheckAuthStatusEvent event, Emitter<AuthState> emit) async {
|
CheckAuthStatusEvent event,
|
||||||
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
final token = await _storage.read(key: 'accessToken');
|
final token = await _storage.read(key: 'accessToken');
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
emit(AuthSuccess());
|
emit(AuthSuccess());
|
||||||
|
|
@ -43,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 {
|
||||||
|
|
@ -52,10 +54,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
);
|
);
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
emit(AuthCodeSentSuccess(
|
emit(
|
||||||
phone: event.phoneNumber,
|
AuthCodeSentSuccess(
|
||||||
countryCode: event.countryCode,
|
phone: event.phoneNumber,
|
||||||
));
|
countryCode: event.countryCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
emit(AuthFailure(response.data['message'] ?? 'خطایی رخ داد'));
|
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());
|
emit(AuthLoading());
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
|
|
@ -97,8 +104,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpdateUserInfo(
|
Future<void> _onUpdateUserInfo(
|
||||||
UpdateUserInfoEvent event, Emitter<AuthState> emit) async {
|
UpdateUserInfoEvent event,
|
||||||
debugPrint("AuthBloc: 🔵 ایونت UpdateUserInfoEvent دریافت شد با نام: ${event.name}");
|
Emitter<AuthState> emit,
|
||||||
|
) async {
|
||||||
|
debugPrint(
|
||||||
|
"AuthBloc: 🔵 ایونت UpdateUserInfoEvent دریافت شد با نام: ${event.name}",
|
||||||
|
);
|
||||||
emit(AuthLoading());
|
emit(AuthLoading());
|
||||||
try {
|
try {
|
||||||
final token = await _storage.read(key: 'accessToken');
|
final token = await _storage.read(key: 'accessToken');
|
||||||
|
|
@ -115,30 +126,59 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint("AuthBloc: 🟠 پاسخ سرور دریافت شد. StatusCode: ${response.statusCode}");
|
debugPrint(
|
||||||
|
"AuthBloc: 🟠 پاسخ سرور دریافت شد. StatusCode: ${response.statusCode}",
|
||||||
|
);
|
||||||
|
|
||||||
if (isClosed) {
|
if (isClosed) {
|
||||||
debugPrint("AuthBloc: 🔴 خطا: BLoC قبل از اتمام عملیات بسته شده است.");
|
debugPrint("AuthBloc: 🔴 خطا: BLoC قبل از اتمام عملیات بسته شده است.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
debugPrint("AuthBloc: ✅ درخواست موفق بود. در حال emit کردن AuthSuccess...");
|
debugPrint(
|
||||||
|
"AuthBloc: ✅ درخواست موفق بود. در حال emit کردن AuthSuccess...",
|
||||||
|
);
|
||||||
emit(AuthSuccess());
|
emit(AuthSuccess());
|
||||||
} else {
|
} else {
|
||||||
debugPrint("AuthBloc: 🔴 سرور پاسخ ناموفق داد: ${response.data['message']}");
|
debugPrint(
|
||||||
|
"AuthBloc: 🔴 سرور پاسخ ناموفق داد: ${response.data['message']}",
|
||||||
|
);
|
||||||
emit(AuthFailure(response.data['message'] ?? 'خطا در ثبت اطلاعات'));
|
emit(AuthFailure(response.data['message'] ?? 'خطا در ثبت اطلاعات'));
|
||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
debugPrint("AuthBloc: 🔴 خطای DioException رخ داد: ${e.response?.data['message']}");
|
debugPrint(
|
||||||
|
"AuthBloc: 🔴 خطای DioException رخ داد: ${e.response?.data['message']}",
|
||||||
|
);
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
emit(AuthFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));
|
emit(AuthFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
|
Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
|
||||||
await _storage.deleteAll();
|
await _storage.deleteAll();
|
||||||
emit(AuthInitial());
|
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']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,9 @@ class UpdateUserInfoEvent extends AuthEvent {
|
||||||
final String gender;
|
final String gender;
|
||||||
|
|
||||||
UpdateUserInfoEvent({required this.name, required this.gender});
|
UpdateUserInfoEvent({required this.name, required this.gender});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendFcmTokenEvent extends AuthEvent {
|
||||||
|
final String fcmToken;
|
||||||
|
SendFcmTokenEvent({required this.fcmToken});
|
||||||
}
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -26,12 +26,12 @@ class NotificationPreferencesBloc
|
||||||
LoadCategories event, Emitter<NotificationPreferencesState> emit) {
|
LoadCategories event, Emitter<NotificationPreferencesState> emit) {
|
||||||
final categories = [
|
final categories = [
|
||||||
CategoryEntity(id: "e33dd7f9-5b20-4273-8eea-59da6ca5f206", name: 'لوازم دیجیتال', icon: Assets.icons.digital),
|
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: "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: "71e371f8-a47a-4a58-aee6-4ed0f26bf29b", name: 'پوشاک', icon: Assets.icons.pooshak),
|
||||||
CategoryEntity(id: "42acff41-1165-4e62-89b9-58db7329ec3a", name: 'تریا', icon: Assets.icons.teria),
|
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: "52c51010-3a63-4264-a350-e011c889f3dd", name: 'سینما', icon: Assets.icons.cinama),
|
||||||
CategoryEntity(id: "34185954-f79f-4b9e-8eb2-1702679c40a0", name: 'لوازم آرایشی', icon: Assets.icons.arayesh),
|
CategoryEntity(id: "34185954-f79f-4b9e-8eb2-1702679c40a0", name: 'لوازم آرایشی', icon: Assets.icons.arayesh),
|
||||||
CategoryEntity(id: "e4517b0c-aacf-4758-94bd-85f45062980f", name: 'طلا و زیورآلات', icon: Assets.icons.tala),
|
CategoryEntity(id: "e4517b0c-aacf-4758-94bd-85f45062980f", name: 'طلا و زیورآلات', icon: Assets.icons.tala),
|
||||||
|
|
@ -126,6 +126,4 @@ class NotificationPreferencesBloc
|
||||||
ResetSubmissionStatus event, Emitter<NotificationPreferencesState> emit) {
|
ResetSubmissionStatus event, Emitter<NotificationPreferencesState> emit) {
|
||||||
emit(state.copyWith(submissionSuccess: false));
|
emit(state.copyWith(submissionSuccess: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,7 +25,6 @@ class AddPhotoScreen extends StatelessWidget {
|
||||||
required this.offer,
|
required this.offer,
|
||||||
});
|
});
|
||||||
|
|
||||||
// متد ساخت توکن
|
|
||||||
Future<String> _generateQrToken(BuildContext context) async {
|
Future<String> _generateQrToken(BuildContext context) async {
|
||||||
const storage = FlutterSecureStorage();
|
const storage = FlutterSecureStorage();
|
||||||
final userID = await storage.read(key: 'userID');
|
final userID = await storage.read(key: 'userID');
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,7 +130,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 +157,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 +176,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),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/reservation/cubit/reservation_cubit.dart';
|
||||||
import 'package:proxibuy/presentation/widgets/gps_dialog.dart';
|
import 'package:proxibuy/presentation/widgets/gps_dialog.dart';
|
||||||
import 'package:proxibuy/presentation/widgets/notification_permission_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:proxibuy/services/mqtt_service.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
|
@ -44,6 +45,11 @@ class _OffersPageState extends State<OffersPage> {
|
||||||
bool _isSubscribedToOffers = false;
|
bool _isSubscribedToOffers = false;
|
||||||
bool _isGpsEnabled = false;
|
bool _isGpsEnabled = false;
|
||||||
bool _isConnectedToInternet = true;
|
bool _isConnectedToInternet = true;
|
||||||
|
// Notifications panel state
|
||||||
|
final GlobalKey _bellKey = GlobalKey();
|
||||||
|
OverlayEntry? _notifOverlay;
|
||||||
|
bool _notifVisible = false;
|
||||||
|
int _notificationCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -52,6 +58,7 @@ class _OffersPageState extends State<OffersPage> {
|
||||||
_initializePage();
|
_initializePage();
|
||||||
_initConnectivityListener();
|
_initConnectivityListener();
|
||||||
_fetchInitialReservations();
|
_fetchInitialReservations();
|
||||||
|
_fetchNotificationCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -60,6 +67,7 @@ class _OffersPageState extends State<OffersPage> {
|
||||||
_mqttMessageSubscription?.cancel();
|
_mqttMessageSubscription?.cancel();
|
||||||
_locationTimer?.cancel();
|
_locationTimer?.cancel();
|
||||||
_connectivitySubscription?.cancel();
|
_connectivitySubscription?.cancel();
|
||||||
|
_removeNotificationOverlay();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,8 +235,8 @@ class _OffersPageState extends State<OffersPage> {
|
||||||
|
|
||||||
final payload = {
|
final payload = {
|
||||||
"userID": userID,
|
"userID": userID,
|
||||||
"lat":32.6685,
|
"lat": position.latitude,
|
||||||
"lng": 51.6826
|
"lng": position.longitude
|
||||||
};
|
};
|
||||||
|
|
||||||
mqttService.publish("proxybuy/sendGps", payload);
|
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() {
|
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),
|
||||||
|
|
@ -381,8 +522,51 @@ class _OffersPageState extends State<OffersPage> {
|
||||||
child: Assets.icons.logoWithName.svg(height: 40, width: 200),
|
child: Assets.icons.logoWithName.svg(height: 40, width: 200),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
// Notification bell with badge and overlay trigger
|
||||||
onPressed: () {}, icon: Assets.icons.notification.svg()),
|
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>(
|
BlocBuilder<ReservationCubit, ReservationState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final reservedCount = state.reservedProductIds.length;
|
final reservedCount = state.reservedProductIds.length;
|
||||||
|
|
@ -445,16 +629,21 @@ class _OffersPageState extends State<OffersPage> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: RefreshIndicator(
|
||||||
child: Column(
|
onRefresh: _onRefresh,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: AppColors.active,
|
||||||
children: [
|
child: SingleChildScrollView(
|
||||||
_buildFavoriteCategoriesSection(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
OffersView(
|
child: Column(
|
||||||
isGpsEnabled: _isGpsEnabled,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
isConnectedToInternet: _isConnectedToInternet,
|
children: [
|
||||||
),
|
_buildFavoriteCategoriesSection(),
|
||||||
],
|
OffersView(
|
||||||
|
isGpsEnabled: _isGpsEnabled,
|
||||||
|
isConnectedToInternet: _isConnectedToInternet,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||||
import 'package:dio/dio.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/api_config.dart';
|
||||||
import 'package:proxibuy/core/config/app_colors.dart';
|
import 'package:proxibuy/core/config/app_colors.dart';
|
||||||
import 'package:proxibuy/core/gen/assets.gen.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/data/models/offer_model.dart';
|
||||||
import 'package:proxibuy/presentation/pages/add_photo_screen.dart';
|
import 'package:proxibuy/presentation/pages/add_photo_screen.dart';
|
||||||
import 'package:proxibuy/presentation/pages/reservation_details_screen.dart';
|
import 'package:proxibuy/presentation/pages/reservation_details_screen.dart';
|
||||||
|
|
@ -87,20 +89,9 @@ class ProductDetailPage extends StatelessWidget {
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
);
|
);
|
||||||
|
|
||||||
debugPrint("----------- REQUEST-----------");
|
|
||||||
debugPrint("URL: POST $url");
|
|
||||||
debugPrint("Headers: ${options.headers}");
|
|
||||||
debugPrint("Body: $data");
|
|
||||||
debugPrint("-----------------------------");
|
|
||||||
|
|
||||||
final response =
|
final response =
|
||||||
await dio.post(url, data: data, options: options);
|
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 (context.mounted) Navigator.of(context).pop();
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
|
@ -131,11 +122,6 @@ class ProductDetailPage extends StatelessWidget {
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
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'] ??
|
final errorMessage = e.response?.data?['message'] ??
|
||||||
'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.';
|
'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -145,10 +131,6 @@ class ProductDetailPage extends StatelessWidget {
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
debugPrint("---------- GENERAL ERROR -----------");
|
|
||||||
debugPrint(e.toString());
|
|
||||||
debugPrint("------------------------------------");
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(e.toString()),
|
content: Text(e.toString()),
|
||||||
|
|
@ -205,12 +187,47 @@ class _ProductDetailViewState extends State<ProductDetailView> {
|
||||||
late List<String> imageList;
|
late List<String> imageList;
|
||||||
late String selectedImage;
|
late String selectedImage;
|
||||||
final String _uploadKey = 'upload_image';
|
final String _uploadKey = 'upload_image';
|
||||||
|
late Future<List<CommentModel>> _commentsFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
imageList = List.from(widget.offer.imageUrls)..add(_uploadKey);
|
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) {
|
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())
|
? widget.offer.expiryTime.difference(DateTime.now())
|
||||||
: Duration.zero;
|
: Duration.zero;
|
||||||
|
|
||||||
final formatCurrency =
|
final formatCurrency = NumberFormat.decimalPattern('fa_IR');
|
||||||
NumberFormat.decimalPattern('fa_IR'); // Or 'en_US'
|
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0)
|
padding: const EdgeInsets.symmetric(horizontal: 24.0)
|
||||||
|
|
@ -663,7 +666,7 @@ class _ProductDetailViewState extends State<ProductDetailView> {
|
||||||
slideDirection: SlideDirection.up,
|
slideDirection: SlideDirection.up,
|
||||||
separator: ':',
|
separator: ':',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 50,
|
fontSize: 45,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.countdown,
|
color: AppColors.countdown,
|
||||||
),
|
),
|
||||||
|
|
@ -687,7 +690,21 @@ class _ProductDetailViewState extends State<ProductDetailView> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildDiscountTypeSection(),
|
_buildDiscountTypeSection(),
|
||||||
const SizedBox(height: 24),
|
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(
|
].animate(interval: 80.ms).slideX(begin: -0.05).fadeIn(
|
||||||
duration: 400.ms,
|
duration: 400.ms,
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.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:flutter_svg/svg.dart';
|
||||||
import 'package:proxibuy/core/config/app_colors.dart';
|
import 'package:proxibuy/core/config/app_colors.dart';
|
||||||
import 'package:proxibuy/core/gen/assets.gen.dart';
|
import 'package:proxibuy/core/gen/assets.gen.dart';
|
||||||
import 'package:proxibuy/data/models/offer_model.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:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
|
||||||
|
|
@ -30,17 +33,17 @@ class _ReservationConfirmationPageState
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
Duration _remaining = Duration.zero;
|
Duration _remaining = Duration.zero;
|
||||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||||
|
StreamSubscription? _mqttSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_playSound();
|
_playSound();
|
||||||
|
|
||||||
_calculateRemainingTime();
|
_calculateRemainingTime();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
_calculateRemainingTime();
|
_calculateRemainingTime();
|
||||||
});
|
});
|
||||||
|
_listenToMqtt();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _playSound() async {
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_audioPlayer.dispose();
|
_audioPlayer.dispose();
|
||||||
|
_mqttSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Directionality(
|
return Directionality(
|
||||||
|
|
@ -94,13 +129,13 @@ class _ReservationConfirmationPageState
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'تخفیف ${widget.offer.discountType} رزرو شد!',
|
'تخفیف ${widget.offer.discountType} رزرو شد!',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.animate()
|
.animate()
|
||||||
.fadeIn(delay: 300.ms, duration: 500.ms)
|
.fadeIn(delay: 300.ms, duration: 500.ms)
|
||||||
.slideY(begin: -0.2, end: 0),
|
.slideY(begin: -0.2, end: 0),
|
||||||
|
|
@ -227,7 +262,7 @@ class _ReservationConfirmationPageState
|
||||||
SvgPicture.asset(Assets.icons.ticketDiscount.path),
|
SvgPicture.asset(Assets.icons.ticketDiscount.path),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'(${(100-widget.offer.finalPrice/widget.offer.originalPrice*100).toInt()}%)',
|
'(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: AppColors.singleOfferType,
|
color: AppColors.singleOfferType,
|
||||||
|
|
@ -401,4 +436,4 @@ class _ReservationConfirmationPageState
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,12 +3,14 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
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';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:proxibuy/core/config/app_colors.dart';
|
import 'package:proxibuy/core/config/app_colors.dart';
|
||||||
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart';
|
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart';
|
||||||
import 'package:proxibuy/presentation/pages/onboarding_page.dart';
|
import 'package:proxibuy/presentation/pages/onboarding_page.dart';
|
||||||
import 'package:proxibuy/presentation/pages/offers_page.dart';
|
import 'package:proxibuy/presentation/pages/offers_page.dart';
|
||||||
import 'package:proxibuy/core/gen/assets.gen.dart';
|
import 'package:proxibuy/core/gen/assets.gen.dart';
|
||||||
import 'package:proxibuy/services/mqtt_service.dart';
|
import 'package:proxibuy/services/mqtt_service.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|
@ -27,10 +29,25 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
super.initState();
|
super.initState();
|
||||||
Future.delayed(const Duration(seconds: 2), _startProcess);
|
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 {
|
Future<void> _startProcess() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await _requestPermissions();
|
||||||
|
|
||||||
final hasInternet = await _checkInternet();
|
final hasInternet = await _checkInternet();
|
||||||
if (!hasInternet) {
|
if (!hasInternet) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -59,12 +76,19 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final String? fcmToken = await getFcmToken();
|
||||||
|
|
||||||
final mqttService = context.read<MqttService>();
|
final mqttService = context.read<MqttService>();
|
||||||
final storage = const FlutterSecureStorage();
|
final storage = const FlutterSecureStorage();
|
||||||
final token = await storage.read(key: 'accessToken');
|
final token = await storage.read(key: 'accessToken');
|
||||||
|
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
|
|
||||||
|
if (fcmToken != null) {
|
||||||
|
context.read<AuthBloc>().add(SendFcmTokenEvent(fcmToken: fcmToken));
|
||||||
|
}
|
||||||
|
|
||||||
if (mqttService.isConnected) {
|
if (mqttService.isConnected) {
|
||||||
_navigateToOffers();
|
_navigateToOffers();
|
||||||
return;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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('بزن بریم'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||||
import 'package:flutter/material.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_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:proxibuy/core/gen/assets.gen.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:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:proxibuy/core/config/app_colors.dart';
|
import 'package:proxibuy/core/config/app_colors.dart';
|
||||||
import 'package:proxibuy/data/models/offer_model.dart';
|
import 'package:proxibuy/data/models/offer_model.dart';
|
||||||
|
|
@ -25,6 +29,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
Duration _remaining = Duration.zero;
|
Duration _remaining = Duration.zero;
|
||||||
Future<String>? _qrTokenFuture;
|
Future<String>? _qrTokenFuture;
|
||||||
|
StreamSubscription? _mqttSubscription; // برای مدیریت لیسنر MQTT
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -57,10 +62,52 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleExpansion() {
|
void _toggleExpansion() {
|
||||||
|
final isExpired = _remaining <= Duration.zero;
|
||||||
|
if (isExpired) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isExpanded = !_isExpanded;
|
_isExpanded = !_isExpanded;
|
||||||
if (_isExpanded && _qrTokenFuture == null) {
|
if (_isExpanded) {
|
||||||
_qrTokenFuture = _generateQrToken();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_mqttSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,24 +152,47 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: _buildOfferPrimaryDetails(),
|
child: _buildOfferPrimaryDetails(),
|
||||||
),
|
),
|
||||||
_buildActionsRow(),
|
_buildActionsRow(isExpired),
|
||||||
_buildExpansionPanel(),
|
if (!isExpired) _buildExpansionPanel(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isExpired) {
|
return Stack(
|
||||||
return ColorFiltered(
|
children: [
|
||||||
colorFilter: const ColorFilter.matrix(<double>[
|
if (isExpired)
|
||||||
0.2126, 0.7152, 0.0722, 0, 0,
|
ColorFiltered(
|
||||||
0.2126, 0.7152, 0.0722, 0, 0,
|
colorFilter: const ColorFilter.matrix(<double>[
|
||||||
0.2126, 0.7152, 0.0722, 0, 0,
|
0.2126, 0.7152, 0.0722, 0, 0,
|
||||||
0, 0, 0, 1, 0,
|
0.2126, 0.7152, 0.0722, 0, 0,
|
||||||
]),
|
0.2126, 0.7152, 0.0722, 0, 0,
|
||||||
child: cardContent,
|
0, 0, 0, 1, 0,
|
||||||
);
|
]),
|
||||||
}
|
child: cardContent,
|
||||||
|
)
|
||||||
return 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() {
|
Widget _buildOfferPrimaryDetails() {
|
||||||
|
|
@ -195,13 +266,13 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActionsRow() {
|
Widget _buildActionsRow(bool isExpired) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
if (_remaining > Duration.zero)
|
if (!isExpired)
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Localizations.override(
|
Localizations.override(
|
||||||
|
|
@ -220,7 +291,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
color: AppColors.countdown,
|
color: AppColors.countdown,
|
||||||
),
|
),
|
||||||
decoration: const BoxDecoration(color: Colors.white),
|
decoration: const BoxDecoration(color: Colors.transparent),
|
||||||
shouldShowDays: (d) => d.inDays > 0,
|
shouldShowDays: (d) => d.inDays > 0,
|
||||||
shouldShowHours: (d) => d.inHours > 0,
|
shouldShowHours: (d) => d.inHours > 0,
|
||||||
shouldShowMinutes: (d) => d.inSeconds > 0,
|
shouldShowMinutes: (d) => d.inSeconds > 0,
|
||||||
|
|
@ -231,36 +302,30 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const Text(
|
const SizedBox(height: 0),
|
||||||
'منقضی شده',
|
SizedBox(width: 10),
|
||||||
style: TextStyle(
|
if (!isExpired)
|
||||||
color: Colors.red,
|
TextButton(
|
||||||
fontWeight: FontWeight.bold,
|
onPressed: _toggleExpansion,
|
||||||
fontSize: 16,
|
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() {
|
Widget _buildExpansionPanel() {
|
||||||
final formatCurrency = NumberFormat.decimalPattern('fa_IR');
|
final formatCurrency = NumberFormat.decimalPattern('fa_IR');
|
||||||
|
final isExpired = _remaining <= Duration.zero;
|
||||||
|
|
||||||
return AnimatedCrossFade(
|
return AnimatedCrossFade(
|
||||||
firstChild: Container(),
|
firstChild: Container(),
|
||||||
|
|
@ -339,8 +405,8 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 20,
|
horizontal: 15,
|
||||||
vertical: 9,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.singleOfferType,
|
color: AppColors.singleOfferType,
|
||||||
|
|
@ -351,7 +417,8 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
fontSize: 17,
|
fontSize: 13,
|
||||||
|
overflow: TextOverflow.ellipsis
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -365,7 +432,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
Text(
|
Text(
|
||||||
'(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)',
|
'(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
color: AppColors.singleOfferType,
|
color: AppColors.singleOfferType,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
),
|
),
|
||||||
|
|
@ -374,7 +441,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
Text(
|
Text(
|
||||||
formatCurrency.format(widget.offer.originalPrice),
|
formatCurrency.format(widget.offer.originalPrice),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade600,
|
||||||
decoration: TextDecoration.lineThrough,
|
decoration: TextDecoration.lineThrough,
|
||||||
),
|
),
|
||||||
|
|
@ -386,7 +453,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
'${formatCurrency.format(widget.offer.finalPrice)} تومان',
|
'${formatCurrency.format(widget.offer.finalPrice)} تومان',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.singleOfferType,
|
color: AppColors.singleOfferType,
|
||||||
fontSize: 18,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -394,39 +461,41 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
if (!isExpired) ...[
|
||||||
Container(
|
const SizedBox(height: 20),
|
||||||
padding: const EdgeInsets.all(20),
|
Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.all(20),
|
||||||
color: Color.fromARGB(255, 246, 246, 246),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
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();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class CustomStarRating extends StatelessWidget {
|
||||||
stars.add(_buildStar(Assets.icons.star2.path));
|
stars.add(_buildStar(Assets.icons.star2.path));
|
||||||
remaining = 0;
|
remaining = 0;
|
||||||
} else {
|
} else {
|
||||||
stars.add(_buildStar(Assets.icons.starHalf.path,));
|
stars.add(_buildStar(Assets.icons.star2.path,));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Row(mainAxisSize: MainAxisSize.min, children: stars);
|
return Row(mainAxisSize: MainAxisSize.min, children: stars);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,13 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
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_client.dart';
|
||||||
import 'package:mqtt_client/mqtt_server_client.dart';
|
import 'package:mqtt_client/mqtt_server_client.dart';
|
||||||
|
|
||||||
class MqttService {
|
class MqttService {
|
||||||
MqttServerClient? client;
|
MqttServerClient? client;
|
||||||
final String server = '5.75.197.180';
|
final String server = '62.60.214.99';
|
||||||
final int port = 1883;
|
final int port = 1883;
|
||||||
final StreamController<Map<String, dynamic>> _messageStreamController =
|
final StreamController<Map<String, dynamic>> _messageStreamController =
|
||||||
StreamController.broadcast();
|
StreamController.broadcast();
|
||||||
|
|
@ -20,7 +21,9 @@ class MqttService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> connect(String token) async {
|
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');
|
'nest-' + Random().nextInt(0xFFFFFF).toRadixString(16).padLeft(6, '0');
|
||||||
final String username = 'ignored';
|
final String username = 'ignored';
|
||||||
final String password = token;
|
final String password = token;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import connectivity_plus
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import firebase_auth
|
import firebase_auth
|
||||||
import firebase_core
|
import firebase_core
|
||||||
|
import firebase_crashlytics
|
||||||
|
import firebase_messaging
|
||||||
import firebase_storage
|
import firebase_storage
|
||||||
|
import flutter_local_notifications
|
||||||
import flutter_localization
|
import flutter_localization
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
|
|
@ -28,7 +31,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
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"))
|
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
|
||||||
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin"))
|
FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
|
|
|
||||||
148
pubspec.lock
148
pubspec.lock
|
|
@ -33,6 +33,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.4.5"
|
version: "7.4.5"
|
||||||
|
animations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: animations
|
||||||
|
sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.11"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -521,6 +529,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.24.1"
|
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:
|
firebase_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -566,6 +614,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
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:
|
flutter_bloc:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -614,6 +694,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
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:
|
flutter_localization:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -635,6 +747,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.28"
|
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:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1121,10 +1241,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: permission_handler
|
name: permission_handler
|
||||||
sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f"
|
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.0.0+1"
|
version: "12.0.1"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1333,6 +1453,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -1466,6 +1594,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.1.5"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.1"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1642,6 +1778,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.13.0"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
11
pubspec.yaml
11
pubspec.yaml
|
|
@ -42,7 +42,7 @@ dependencies:
|
||||||
flutter_gen: ^5.10.0
|
flutter_gen: ^5.10.0
|
||||||
country_picker: ^2.0.27
|
country_picker: ^2.0.27
|
||||||
geolocator: ^14.0.1
|
geolocator: ^14.0.1
|
||||||
permission_handler: ^12.0.0+1
|
permission_handler: ^12.0.1
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
collection: ^1.19.1
|
collection: ^1.19.1
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
|
|
@ -62,6 +62,15 @@ dependencies:
|
||||||
dart_jsonwebtoken: ^3.2.0
|
dart_jsonwebtoken: ^3.2.0
|
||||||
audioplayers: ^6.5.0
|
audioplayers: ^6.5.0
|
||||||
intl: ^0.19.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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue