Finished Base

This commit is contained in:
mohamadmahdi jebeli 2025-07-26 14:32:07 +03:30
parent 050fb6b620
commit 6f6e34e391
30 changed files with 1384 additions and 861 deletions

Binary file not shown.

View File

@ -4,5 +4,7 @@ class ApiConfig {
static const String verifyCode = "/login/getcode";
static const String updateUser = "/user/updateName";
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 getReservations = "/reservation/get"; // <-- این خط اضافه شد
}

View File

@ -0,0 +1,12 @@
// lib/core/config/http_overrides.dart
import 'dart:io';
class MyHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
}
}

View File

@ -1,11 +1,7 @@
// lib/data/models/offer_model.dart
import 'package:equatable/equatable.dart';
import 'package:proxibuy/data/models/comment_model.dart';
import 'package:proxibuy/data/models/discount_info_model.dart';
import 'package:proxibuy/data/models/working_hours.dart';
// کلاس کمکی برای دادههای فروشگاه
class ShopData {
final String id;
final String name;
@ -31,8 +27,8 @@ class ShopData {
name: json['Name'] ?? 'نام فروشگاه نامشخص',
category: json['Category'] ?? 'بدون دسته‌بندی',
address: json['Address'] ?? 'آدرس نامشخص',
latitude: (json['Map']['coordinates'][1] as num?)?.toDouble() ?? 0.0,
longitude: (json['Map']['coordinates'][0] as num?)?.toDouble() ?? 0.0,
latitude: (json['Map']?['coordinates']?[1] as num?)?.toDouble() ?? 0.0,
longitude: (json['Map']?['coordinates']?[0] as num?)?.toDouble() ?? 0.0,
properties: List<String>.from(json['Property'] ?? []),
);
}
@ -58,9 +54,8 @@ class OfferModel extends Equatable {
final double originalPrice;
final double finalPrice;
final List<String> features;
final DiscountInfoModel? discountInfo;
final String discountInfo;
final List<CommentModel> comments;
final String qrCodeData;
const OfferModel({
required this.id,
@ -82,92 +77,82 @@ class OfferModel extends Equatable {
required this.originalPrice,
required this.finalPrice,
this.features = const [],
this.discountInfo,
required this.discountInfo,
this.comments = const [],
required this.qrCodeData,
});
factory OfferModel.fromJson(Map<String, dynamic> json) { // <-- پارامتر calculatedDistance حذف شد
final shopData = ShopData.fromJson(json['shopData']);
factory OfferModel.fromJson(Map<String, dynamic> json) {
final shopDataJson = json['shopData'] as Map<String, dynamic>? ?? {};
final shopData = ShopData.fromJson(shopDataJson);
final now = DateTime.now();
bool checkIsOpen = false;
try {
final startTimeParts = (json['StartTime'] as String).split(':');
final endTimeParts = (json['EndTime'] as String).split(':');
final startHour = int.parse(startTimeParts[0]);
final startMinute = int.parse(startTimeParts[1]);
final endHour = int.parse(endTimeParts[0]);
final endMinute = int.parse(endTimeParts[1]);
final startTimeString = json['StartTime'] as String?;
final endTimeString = json['EndTime'] as String?;
if (startTimeString != null && endTimeString != null) {
final startTimeParts = startTimeString.split(':');
final endTimeParts = endTimeString.split(':');
final startHour = int.parse(startTimeParts[0]);
final startMinute = int.parse(startTimeParts[1]);
final endHour = int.parse(endTimeParts[0]);
final endMinute = int.parse(endTimeParts[1]);
final startTime = DateTime(now.year, now.month, now.day, startHour, startMinute);
final endTime = DateTime(now.year, now.month, now.day, endHour, endMinute);
final startTime = DateTime(now.year, now.month, now.day, startHour, startMinute);
final endTime = DateTime(now.year, now.month, now.day, endHour, endMinute);
checkIsOpen = now.isAfter(startTime) && now.isBefore(endTime);
checkIsOpen = now.isAfter(startTime) && now.isBefore(endTime);
}
} catch(e) {
checkIsOpen = false;
}
final originalPriceValue = (json['Price'] as num?)?.toDouble() ?? 0.0;
final finalPriceValue = (json['NPrice'] as num?)?.toDouble() ?? 0.0;
// **رفع خطا: تبدیل امن عدد فاصله به int**
final distanceFromServer = (json['distance'] as num?)?.round() ?? 0;
// ******** شروع تغییر ۱ ********
// مدیریت هوشمند فاصله و عکسها از منابع مختلف
final distanceFromServer = (json['distance'] as num?)?.round() ?? (json['Distance'] as num?)?.round() ?? 0;
List<String> images = [];
if (json['imageData'] is List) { // برای سازگاری با پاسخ MQTT
images = (json['imageData'] as List<dynamic>)
.map((imageData) => imageData['Url']?.toString() ?? '')
.where((url) => url.isNotEmpty)
.toList();
} else if (json['Images'] is List) { // برای پاسخ جدید API رزرو
images = (json['Images'] as List<dynamic>)
.map((imageData) => (imageData is Map) ? imageData['Url']?.toString() ?? '' : '')
.where((url) => url.isNotEmpty)
.toList();
}
final typeData = json['typeData'] ?? json['Type'];
final discountTypeName = (typeData is Map) ? typeData['Name']?.toString() : typeData?.toString();
// ******** پایان تغییر ۱ ********
return OfferModel(
id: json['ID'] ?? '',
title: json['Name'] ?? 'بدون عنوان',
discount: json['Description'] ?? '',
imageUrls: (json['Images'] as List<dynamic>?)
?.map((imgId) => "$imgId")
.toList() ?? [],
category: json['shopData']['Category']?.toString() ?? 'بدون دسته‌بندی',
imageUrls: images, // <-- استفاده از لیست جدید
category: json['categoryData']?['Name']?.toString() ?? '',
expiryTime: DateTime.tryParse(json['EndDate'] ?? '') ?? DateTime.now().add(const Duration(days: 1)),
discountType: json['Type']?.toString() ?? '',
discountType: discountTypeName ?? '',
originalPrice: originalPriceValue,
finalPrice: finalPriceValue,
qrCodeData: json['QRcode'] ?? '',
storeName: shopData.name,
address: shopData.address,
latitude: shopData.latitude,
longitude: shopData.longitude,
features: shopData.properties,
distanceInMeters: distanceFromServer, // <-- **استفاده از مسافت سرور**
distanceInMeters: distanceFromServer, // <-- استفاده از متغیر جدید
isOpen: checkIsOpen,
workingHours: [],
rating: 0.0,
ratingCount: 0,
comments: [],
discountInfo: null,
);
}
OfferModel copyWith() {
return OfferModel(
id: id,
storeName: storeName,
title: title,
discount: discount,
imageUrls: imageUrls,
category: category,
distanceInMeters: distanceInMeters,
expiryTime: expiryTime,
address: address,
workingHours: workingHours,
discountType: discountType,
isOpen: isOpen,
rating: rating,
ratingCount: ratingCount,
latitude: latitude,
longitude: longitude,
originalPrice: originalPrice,
finalPrice: finalPrice,
features: features,
discountInfo: discountInfo,
comments: comments,
qrCodeData: qrCodeData,
discountInfo: json['Description'],
);
}

View File

@ -1,11 +1,11 @@
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:proxibuy/data/models/datasources/offer_data_source.dart';
import 'package:proxibuy/data/repositories/offer_repository.dart';
import 'package:proxibuy/core/config/http_overrides.dart'; // این خط را اضافه کنید
import 'package:proxibuy/firebase_options.dart';
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart';
import 'package:proxibuy/presentation/notification_preferences/bloc/notification_preferences_bloc.dart';
@ -18,6 +18,8 @@ import 'package:proxibuy/presentation/pages/splash_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = MyHttpOverrides();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
Animate.restartOnHotReload = true;
runApp(const MyApp());
@ -36,7 +38,6 @@ class MyApp extends StatelessWidget {
BlocProvider<AuthBloc>(
create: (context) => AuthBloc()..add(CheckAuthStatusEvent()),
),
// RepositoryProvider برای OfferRepository حذف شد
BlocProvider<ReservationCubit>(
create: (context) => ReservationCubit(),
),
@ -46,6 +47,7 @@ class MyApp extends StatelessWidget {
BlocProvider<NotificationPreferencesBloc>(
create: (context) => NotificationPreferencesBloc(),
),
],
child: MaterialApp(
title: 'Proxibuy',
@ -124,38 +126,4 @@ class MyApp extends StatelessWidget {
),
);
}
}
// class AppRouter extends StatelessWidget {
// const AppRouter({super.key});
// @override
// Widget build(BuildContext context) {
// final authState = context.select((AuthBloc bloc) => bloc.state);
// if (authState is AuthCodeSentSuccess) {
// return OtpPage(
// phoneNumber: "+${authState.countryCode}${authState.phone}",
// phone: authState.phone,
// countryCode: authState.countryCode,
// );
// }
// if (authState is AuthLoading) {
// final currentState = context.read<AuthBloc>().state;
// if (currentState is! AuthCodeSentSuccess) {
// return const Scaffold(body: Center(child: CircularProgressIndicator()));
// }
// }
// if (authState is AuthSuccess) {
// return const OffersPage();
// }
// if (authState is AuthNeedsInfo) {
// return const UserInfoPage();
// }
// return const OnboardingPage();
// }
// }
}

View File

@ -3,6 +3,7 @@
import 'package:bloc/bloc.dart';
import 'package:dio/dio.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:meta/meta.dart';
import 'package:proxibuy/core/config/api_config.dart';
@ -49,6 +50,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
ApiConfig.baseUrl + ApiConfig.sendCode,
data: {'Phone': event.phoneNumber, 'Code': event.countryCode},
);
if (isClosed) return;
if (response.statusCode == 200) {
emit(AuthCodeSentSuccess(
phone: event.phoneNumber,
@ -58,6 +60,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
emit(AuthFailure(response.data['message'] ?? 'خطایی رخ داد'));
}
} on DioException catch (e) {
if (isClosed) return;
emit(AuthFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));
}
}
@ -73,46 +76,74 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
'OTP': event.otp,
},
);
if (isClosed) return; // FIX: Add check here
if (response.statusCode == 200) {
final accessToken = response.data['data']['accessToken'];
final refreshToken = response.data['data']['refreshToken'];
final userID = response.data['data']['ID']; // <-- خط جدید: استخراج ID
final userID = response.data['data']['ID'];
await _storage.write(key: 'accessToken', value: accessToken);
await _storage.write(key: 'refreshToken', value: refreshToken);
await _storage.write(key: 'userID', value: userID); // <-- خط جدید: ذخیره ID
await _storage.write(key: 'userID', value: userID);
emit(AuthNeedsInfo());
} else {
emit(AuthFailure(response.data['message'] ?? 'کد صحیح نیست'));
}
} on DioException catch (e) {
if (isClosed) return; // FIX: Add check here
emit(AuthFailure(e.response?.data['message'] ?? 'خطایی در سرور رخ داد'));
}
}
Future<void> _onUpdateUserInfo(
UpdateUserInfoEvent event, Emitter<AuthState> emit) async {
// ******** لاگ ۱: شروع پردازش ********
debugPrint("AuthBloc: 🔵 ایونت UpdateUserInfoEvent دریافت شد با نام: ${event.name}");
emit(AuthLoading());
try {
final token = await _storage.read(key: 'accessToken');
if (token == null) {
// ******** لاگ خطا: توکن وجود ندارد ********
debugPrint("AuthBloc: 🔴 خطا: توکن کاربر یافت نشد.");
emit(const AuthFailure("شما وارد نشده‌اید."));
return;
}
// ******** لاگ ۲: ارسال درخواست به سرور ********
debugPrint("AuthBloc: 🟡 در حال ارسال درخواست آپدیت به سرور...");
final response = await _dio.post(
ApiConfig.baseUrl + ApiConfig.updateUser,
data: {'Name': event.name, 'Gender': event.gender},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
// ******** لاگ ۳: پاسخ سرور ********
debugPrint("AuthBloc: 🟠 پاسخ سرور دریافت شد. StatusCode: ${response.statusCode}");
if (isClosed) {
debugPrint("AuthBloc: 🔴 خطا: BLoC قبل از اتمام عملیات بسته شده است.");
return;
}
if (response.statusCode == 200) {
// ******** لاگ ۴: موفقیتآمیز بودن عملیات ********
debugPrint("AuthBloc: ✅ درخواست موفق بود. در حال emit کردن AuthSuccess...");
emit(AuthSuccess());
} else {
// ******** لاگ خطا: پاسخ ناموفق از سرور ********
debugPrint("AuthBloc: 🔴 سرور پاسخ ناموفق داد: ${response.data['message']}");
emit(AuthFailure(response.data['message'] ?? 'خطا در ثبت اطلاعات'));
}
} on DioException catch (e) {
// ******** لاگ خطا: خطای Dio ********
debugPrint("AuthBloc: 🔴 خطای DioException رخ داد: ${e.response?.data['message']}");
if (isClosed) return;
emit(AuthFailure(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));
}
}
Future<void> _onLogout(LogoutEvent event, Emitter<AuthState> emit) async {
await _storage.deleteAll();
emit(AuthInitial());

View File

@ -16,7 +16,7 @@ class NotificationPreferencesBloc
on<LoadCategories>(_onLoadCategories);
on<ToggleCategorySelection>(_onToggleCategorySelection);
on<SubmitPreferences>(_onSubmitPreferences);
on<LoadFavoriteCategories>(_onLoadFavoriteCategories); // این خط اضافه شد
on<LoadFavoriteCategories>(_onLoadFavoriteCategories);
add(LoadCategories());
}
@ -55,6 +55,7 @@ class NotificationPreferencesBloc
try {
final token = await _storage.read(key: 'accessToken');
if (token == null) {
if (isClosed) return; // بررسی قبل از emit
emit(state.copyWith(isLoading: false, errorMessage: "شما وارد نشده‌اید."));
return;
}
@ -65,6 +66,8 @@ class NotificationPreferencesBloc
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (isClosed) return; // بررسی قبل از emit
if (response.statusCode == 200) {
emit(state.copyWith(isLoading: false, submissionSuccess: true));
} else {
@ -73,19 +76,20 @@ class NotificationPreferencesBloc
errorMessage: response.data['message'] ?? 'خطا در ثبت اطلاعات'));
}
} on DioException catch (e) {
if (isClosed) return; // بررسی قبل از emit
emit(state.copyWith(
isLoading: false,
errorMessage: e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));
}
}
// این متد اضافه شد
Future<void> _onLoadFavoriteCategories(
LoadFavoriteCategories event, Emitter<NotificationPreferencesState> emit) async {
emit(state.copyWith(isLoading: true, errorMessage: null));
try {
final token = await _storage.read(key: 'accessToken');
if (token == null) {
if (isClosed) return; // بررسی قبل از emit
emit(state.copyWith(isLoading: false, errorMessage: "شما وارد نشده‌اید."));
return;
}
@ -95,6 +99,8 @@ class NotificationPreferencesBloc
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (isClosed) return; // بررسی قبل از emit
if (response.statusCode == 200) {
final List<dynamic> fCategory = response.data['data']['FCategory'];
final Set<String> favoriteCategoryIds =
@ -109,6 +115,7 @@ class NotificationPreferencesBloc
errorMessage: response.data['message'] ?? 'خطا در دریافت اطلاعات'));
}
} on DioException catch (e) {
if (isClosed) return; // بررسی قبل از emit
emit(state.copyWith(
isLoading: false,
errorMessage: e.response?.data['message'] ?? 'خطا در ارتباط با سرور'));

View File

@ -1,5 +1,3 @@
// lib/presentation/offer/bloc/offer_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:proxibuy/presentation/offer/bloc/offer_event.dart';
import 'package:proxibuy/presentation/offer/bloc/offer_state.dart';
@ -7,35 +5,21 @@ import 'package:proxibuy/presentation/offer/bloc/offer_state.dart';
class OffersBloc extends Bloc<OffersEvent, OffersState> {
OffersBloc() : super(OffersInitial()) {
on<OffersReceivedFromMqtt>(_onOffersReceivedFromMqtt);
on<ClearOffers>(_onClearOffers); // رویداد جدید برای پاک کردن دیتا
on<ClearOffers>(_onClearOffers);
}
void _onOffersReceivedFromMqtt(
OffersReceivedFromMqtt event,
Emitter<OffersState> emit,
) {
// فقط در صورتی که لیست جدید خالی نباشد، آن را جایگزین کن
if (event.offers.isNotEmpty) {
emit(OffersLoadSuccess(event.offers));
}
// اگر لیست جدید خالی بود، و قبلا دیتایی داشتیم، حالت را تغییر نده
// این کار از نمایش صفحه خالی جلوگیری میکند
else if (state is! OffersLoadSuccess) {
// اگر اولین بار است و لیست خالی است، حالت موفقیت با لیست خالی را نشان بده
emit(const OffersLoadSuccess([]));
}
}
// برای زمانی که مثلا کاربر GPS را خاموش میکند
void _onClearOffers(ClearOffers event, Emitter<OffersState> emit) {
emit(OffersInitial());
}
}
// مدیریت رویداد جدید
void _onOffersReceivedFromMqtt(
OffersReceivedFromMqtt event,
Emitter<OffersState> emit,
) {
// جایگزین کردن لیست پیشنهادها با دادههای جدید
// همیشه حالت موفقیت را با لیست دریافتی منتشر کن (حتی اگر خالی باشد).
// این کار تضمین میکند که صفحه از حالت لودینگ خارج میشود و رابط کاربری
// آخرین وضعیت (وجود یا عدم وجود تخفیف) را نمایش میدهد.
emit(OffersLoadSuccess(event.offers));
}
// برای زمانی که مثلا کاربر GPS را خاموش میکند
void _onClearOffers(ClearOffers event, Emitter<OffersState> emit) {
emit(OffersInitial());
}
}

View File

@ -44,8 +44,7 @@ class CategoryOffersRow extends StatelessWidget {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) {
// کل آبجکت offer پاس داده میشود
return ProductDetailPage(offer: offer);
return ProductDetailPage(offer: offer,);
},
),
);

View File

@ -33,7 +33,11 @@ class _OfferCardState extends State<OfferCard> {
padding: const EdgeInsets.only(bottom: 10),
child: Column(
children: [
Stack(children: [_buildOfferImage()]),
// Hero ویجت به اینجا اضافه شد
Hero(
tag: 'offer_image_${widget.offer.id}',
child: _buildOfferImage(),
),
_buildInfoContainer(textTheme),
],
),
@ -53,18 +57,16 @@ class _OfferCardState extends State<OfferCard> {
height: 140,
width: double.infinity,
fit: BoxFit.cover,
placeholder:
(context, url) => Container(
height: 140,
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
),
errorWidget:
(context, url, error) => Container(
height: 140,
color: Colors.grey[300],
child: const Icon(Icons.broken_image, color: Colors.grey),
),
placeholder: (context, url) => Container(
height: 140,
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => Container(
height: 140,
color: Colors.grey[300],
child: const Icon(Icons.broken_image, color: Colors.grey),
),
),
);
}
@ -80,7 +82,6 @@ class _OfferCardState extends State<OfferCard> {
),
boxShadow: [
BoxShadow(
// ignore: deprecated_member_use
color: Colors.grey.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 5,
@ -117,7 +118,6 @@ class _OfferCardState extends State<OfferCard> {
],
),
const SizedBox(height: 10),
Row(
children: [
SvgPicture.asset(Assets.icons.location.path),
@ -127,26 +127,25 @@ class _OfferCardState extends State<OfferCard> {
widget.offer.address,
style: textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Text(
'(${widget.offer.distanceInMeters.toString()}متر تا تخفیف)',
'(${widget.offer.distanceInMeters.toString()} متر تا تخفیف)',
style: textTheme.bodySmall,
),
],
),
const SizedBox(height: 10),
Row(
children: [
SvgPicture.asset(Assets.icons.routing.path),
const SizedBox(width: 4),
Text(
'نوع تخفیف : ${widget.offer.discount} ${widget.offer.discountType}',
style: const TextStyle(color: Color.fromARGB(255, 183, 28, 28),fontSize: 14),
'نوع تخفیف : ${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}% ${widget.offer.discountType}',
style: const TextStyle(
color: Color.fromARGB(255, 183, 28, 28), fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),

View File

@ -1,7 +1,10 @@
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/data/models/offer_model.dart';
@ -9,7 +12,6 @@ import 'package:proxibuy/features/add_photo/cubit/add_photo_cubit.dart';
import 'package:proxibuy/presentation/pages/reservation_details_screen.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
import 'package:proxibuy/presentation/widgets/flutter_staggered_grid_view.dart';
import 'package:image_picker/image_picker.dart'; // ایمپورت جدید
class AddPhotoScreen extends StatelessWidget {
final String storeName;
@ -23,6 +25,31 @@ class AddPhotoScreen extends StatelessWidget {
required this.offer,
});
// متد ساخت توکن
Future<String> _generateQrToken(BuildContext context) async {
const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID');
if (userID == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("خطا: کاربر شناسایی نشد.")),
);
throw Exception("User ID not found");
}
final payload = {
'userID': userID,
'discountID': offer.id,
'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
const secretKey = 'your_super_secret_key_for_qr';
final jwt = JWT(payload);
final token = jwt.sign(SecretKey(secretKey));
return token;
}
void _showImageSourceActionSheet(BuildContext context) {
showModalBottomSheet(
context: context,
@ -34,7 +61,8 @@ class AddPhotoScreen extends StatelessWidget {
child: Wrap(
children: <Widget>[
ListTile(
leading: const Icon(Icons.photo_library, color: AppColors.primary),
leading:
const Icon(Icons.photo_library, color: AppColors.primary),
title: const Text('انتخاب از گالری'),
onTap: () {
Navigator.of(bottomSheetContext).pop();
@ -42,7 +70,8 @@ class AddPhotoScreen extends StatelessWidget {
},
),
ListTile(
leading: const Icon(Icons.camera_alt, color: AppColors.primary),
leading:
const Icon(Icons.camera_alt, color: AppColors.primary),
title: const Text('گرفتن عکس با دوربین'),
onTap: () {
Navigator.of(bottomSheetContext).pop();
@ -68,7 +97,9 @@ class AddPhotoScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 10,),
SizedBox(
height: 10,
),
_buildHeader()
.animate()
.fadeIn(duration: 500.ms)
@ -185,7 +216,6 @@ class AddPhotoScreen extends StatelessWidget {
),
boxShadow: [
BoxShadow(
// ignore: deprecated_member_use
color: Colors.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
@ -288,18 +318,27 @@ class AddPhotoScreen extends StatelessWidget {
),
),
onPressed: () async {
context.read<ReservationCubit>().reserveProduct(
productId,
);
Navigator.of(dialogContext).pop();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReservationConfirmationPage(
offer: offer,
try {
final qrToken = await _generateQrToken(context);
context
.read<ReservationCubit>()
.reserveProduct(productId);
Navigator.of(dialogContext).pop();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReservationConfirmationPage(
offer: offer,
qrCodeData: qrToken,
),
),
),
);
);
} catch (e) {
print("Error in reservation popup: $e");
}
},
child: const Text(
"رزرو محصول",
@ -318,7 +357,6 @@ class AddPhotoScreen extends StatelessWidget {
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
// ignore: deprecated_member_use
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),

View File

@ -1,6 +1,9 @@
// lib/presentation/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:country_picker/country_picker.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart';
import 'package:proxibuy/presentation/pages/otp_page.dart';
import '../../core/gen/assets.gen.dart';
@ -18,13 +21,12 @@ class _LoginPageState extends State<LoginPage> {
void _sendOtp() {
context.read<AuthBloc>().add(
SendOTPEvent(
phoneNumber: _phoneController.text,
countryCode: _selectedCountry.phoneCode,
),
);
SendOTPEvent(
phoneNumber: _phoneController.text,
countryCode: _selectedCountry.phoneCode,
),
);
}
// bool _keepSignedIn = false;
@override
void dispose() {
@ -46,7 +48,10 @@ class _LoginPageState extends State<LoginPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(child: Assets.icons.logo.svg(height: 160)),
Hero(
tag: 'auth_logo',
child: Center(child: Assets.icons.logo.svg(height: 160)),
),
const SizedBox(height: 48),
Text(
"ورود",
@ -97,19 +102,6 @@ class _LoginPageState extends State<LoginPage> {
),
),
),
const SizedBox(height: 16),
// Row(
// children: [
// Checkbox(
// value: _keepSignedIn,
// onChanged:
// (value) =>
// setState(() => _keepSignedIn = value ?? false),
// activeColor: AppColors.primary,
// ),
// Text("مرا به خاطر بسپار", style: textTheme.bodyMedium),
// ],
// ),
const SizedBox(height: 24),
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
@ -122,18 +114,20 @@ class _LoginPageState extends State<LoginPage> {
);
}
if (state is AuthCodeSentSuccess) {
// ******** شروع تغییر ۱ ********
// BlocProvider.value حذف شد
Navigator.push(
context,
MaterialPageRoute(
builder:
(_) => OtpPage(
phoneNumber:
"${state.countryCode}${state.phone}",
phone: state.phone,
countryCode: state.countryCode,
),
builder: (_) => OtpPage(
phoneNumber:
"${state.countryCode}${state.phone}",
phone: state.phone,
countryCode: state.countryCode,
),
),
);
// ******** پایان تغییر ۱ ********
}
},
builder: (context, state) {
@ -143,17 +137,16 @@ class _LoginPageState extends State<LoginPage> {
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading ? null : _sendOtp,
child:
isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: const Text("کد یکبار مصرف"),
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: const Text("کد یکبار مصرف"),
),
);
},
@ -183,12 +176,13 @@ class _LoginPageState extends State<LoginPage> {
"ورود با حساب گوگل",
style: TextStyle(color: Colors.black),
),
onPressed: () {
// TODO: Implement Google Sign-in
},
onPressed: () {},
),
),
],
]
.animate(interval: 100.ms)
.fadeIn(duration: 400.ms, curve: Curves.easeOut)
.slideY(begin: 0.2, duration: 400.ms, curve: Curves.easeOut),
),
),
),
@ -214,4 +208,4 @@ class _LoginPageState extends State<LoginPage> {
onSelect: (Country country) => setState(() => _selectedCountry = country),
);
}
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/presentation/notification_preferences/bloc/notification_preferences_bloc.dart';
@ -9,40 +10,52 @@ import 'package:proxibuy/presentation/pages/offers_page.dart';
import 'package:proxibuy/presentation/pages/reserved_list_page.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
import 'package:proxibuy/presentation/widgets/category_selection_card.dart';
import 'package:proxibuy/services/mqtt_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class NotificationPreferencesPage extends StatefulWidget {
// This parameter is used to decide whether to fetch favorite categories on start
final bool loadFavoritesOnStart;
// The constructor now accepts the 'loadFavoritesOnStart' parameter
const NotificationPreferencesPage({super.key, this.loadFavoritesOnStart = false});
static Route<bool> route({bool loadFavorites = false}) {
return MaterialPageRoute<bool>(
builder: (_) => BlocProvider(
create: (context) => NotificationPreferencesBloc(),
// The widget is created here, passing the parameter correctly
child: NotificationPreferencesPage(loadFavoritesOnStart: loadFavorites),
),
);
}
const NotificationPreferencesPage(
{super.key, this.loadFavoritesOnStart = false});
@override
State<NotificationPreferencesPage> createState() =>
_NotificationPreferencesPageState();
}
class _NotificationPreferencesPageState extends State<NotificationPreferencesPage> {
class _NotificationPreferencesPageState
extends State<NotificationPreferencesPage> {
@override
void initState() {
super.initState();
// If the flag is true, dispatch the event to load favorites from the API
if (widget.loadFavoritesOnStart) {
context.read<NotificationPreferencesBloc>().add(LoadFavoriteCategories());
}
}
void _showConnectingDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return const Dialog(
child: Padding(
padding: EdgeInsets.all(20.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(width: 20),
Text("در حال اتصال..."),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -117,19 +130,41 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
const SizedBox(width: 8),
],
),
body: BlocListener<NotificationPreferencesBloc, NotificationPreferencesState>(
listener: (context, state) {
body: BlocListener<NotificationPreferencesBloc,
NotificationPreferencesState>(
listener: (context, state) async {
if (state.submissionSuccess) {
// Pop the page and return 'true' to signal a successful update
if (Navigator.canPop(context)) {
Navigator.of(context).pop(true);
} else {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const OffersPage(showDialogsOnLoad: true),
),
);
_showConnectingDialog(context);
try {
final mqttService = context.read<MqttService>();
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token != null && token.isNotEmpty) {
await mqttService.connect(token);
if (mounted && mqttService.isConnected) {
Navigator.of(context).pop();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => OffersPage(),
),
);
}
} else {
if (mounted) Navigator.of(context).pop();
}
} catch (e) {
if (mounted) Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("خطا در اتصال: $e")),
);
}
}
}
if (state.errorMessage != null) {
@ -168,7 +203,8 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
TextSpan(
text:
'ترجیح می‌دی از کدام دسته‌بندی‌ها اعلان تخفیف دریافت کنی؟ ',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
TextSpan(text: '(حداقل یک مورد رو انتخاب کن).'),
],
@ -176,7 +212,8 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
),
const SizedBox(height: 24),
Expanded(
child: BlocBuilder<NotificationPreferencesBloc, NotificationPreferencesState>(
child: BlocBuilder<NotificationPreferencesBloc,
NotificationPreferencesState>(
builder: (context, state) {
if (state.categories.isEmpty && state.isLoading) {
return const Center(child: CircularProgressIndicator());
@ -186,7 +223,10 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
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 itemWidth = (screenWidth -
(horizontalPadding * 2) -
(crossAxisSpacing * (crossAxisCount - 1))) /
crossAxisCount;
final itemHeight = itemWidth / 0.9;
return SingleChildScrollView(
@ -204,11 +244,11 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
name: category.name,
icon: category.icon,
isSelected: isSelected,
showSelectableIndicator: state.selectedCategoryIds.isNotEmpty,
showSelectableIndicator:
state.selectedCategoryIds.isNotEmpty,
onTap: () {
context
.read<NotificationPreferencesBloc>()
.add(ToggleCategorySelection(category.id));
context.read<NotificationPreferencesBloc>().add(
ToggleCategorySelection(category.id));
},
),
);
@ -218,9 +258,11 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
},
),
),
BlocBuilder<NotificationPreferencesBloc, NotificationPreferencesState>(
BlocBuilder<NotificationPreferencesBloc,
NotificationPreferencesState>(
builder: (context, state) {
final areCategoriesSelected = state.selectedCategoryIds.isNotEmpty;
final areCategoriesSelected =
state.selectedCategoryIds.isNotEmpty;
if (areCategoriesSelected) {
return SizedBox(
@ -228,16 +270,23 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
child: ElevatedButton(
onPressed: !state.isLoading
? () async {
final bloc = context.read<NotificationPreferencesBloc>();
final selectedCategoryNames = bloc.state.categories
.where((cat) => bloc.state.selectedCategoryIds.contains(cat.id))
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);
final prefs =
await SharedPreferences.getInstance();
await prefs.setStringList(
'user_selected_categories',
selectedCategoryNames);
bloc.add(SubmitPreferences());
}
: null,
@ -251,7 +300,8 @@ class _NotificationPreferencesPageState extends State<NotificationPreferencesPag
),
),
child: state.isLoading
? const CircularProgressIndicator(color: Colors.white)
? const CircularProgressIndicator(
color: Colors.white)
: const Text(
'اعمال',
style: TextStyle(

View File

@ -1,13 +1,14 @@
// lib/presentation/pages/offers_page.dart
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/svg.dart';
import 'package:geolocator/geolocator.dart';
import 'package:proxibuy/core/config/api_config.dart';
import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/data/models/offer_model.dart';
@ -18,11 +19,12 @@ import 'package:proxibuy/presentation/offer/bloc/widgets/category_offers_row.dar
import 'package:proxibuy/presentation/pages/notification_preferences_page.dart';
import 'package:proxibuy/presentation/pages/reserved_list_page.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
import 'package:proxibuy/presentation/widgets/gps_dialog.dart';
import 'package:proxibuy/presentation/widgets/notification_permission_dialog.dart';
import 'package:proxibuy/services/mqtt_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class OffersPage extends StatefulWidget {
// این پارامتر دیگر استفاده نمیشود اما برای سازگاری باقی میماند
final bool showDialogsOnLoad;
const OffersPage({super.key, this.showDialogsOnLoad = false});
@ -35,6 +37,7 @@ class _OffersPageState extends State<OffersPage> {
List<String> _selectedCategories = [];
StreamSubscription? _locationServiceSubscription;
StreamSubscription? _mqttMessageSubscription;
StreamSubscription? _connectivitySubscription;
Timer? _locationTimer;
bool _isSubscribedToOffers = false;
bool _isGpsEnabled = false;
@ -43,19 +46,99 @@ class _OffersPageState extends State<OffersPage> {
void initState() {
super.initState();
_initializePage();
_initConnectivityListener();
_fetchInitialReservations(); // <-- فراخوانی متد جدید
}
// ******** شروع متد جدید ********
// این متد تعداد اولیه رزروها را برای نمایش روی آیکون دریافت میکند
Future<void> _fetchInitialReservations() async {
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token == null) return; // کاربر لاگین نکرده است
final dio = Dio();
final response = await dio.get(
ApiConfig.baseUrl + ApiConfig.getReservations,
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (response.statusCode == 200 && mounted) {
final List<dynamic> reserves = response.data['reserves'];
final List<String> reservedIds =
reserves
.map(
(reserveData) =>
(reserveData['Discount']['ID'] as String?) ?? '',
)
.where((id) => id.isNotEmpty)
.toList();
// بهروزرسانی Cubit با لیست شناسهها
context.read<ReservationCubit>().setReservedIds(reservedIds);
}
} catch (e) {
// اگر خطایی رخ دهد، مشکلی نیست. فقط عدد نمایش داده نمیشود
debugPrint("Error fetching initial reservations: $e");
}
}
// ******** پایان متد جدید ********
Future<void> _initializePage() async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
await showNotificationPermissionDialog(context);
await showGPSDialog(context);
}
});
await _loadPreferences();
_subscribeToUserOffersOnLoad();
_checkAndConnectMqtt();
_initLocationListener();
}
void _initConnectivityListener() {
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((
List<ConnectivityResult> results,
) {
if (!results.contains(ConnectivityResult.none)) {
print(" Network connection restored.");
_checkAndConnectMqtt();
} else {
print(" Network connection lost.");
}
});
}
Future<void> _checkAndConnectMqtt() async {
final mqttService = context.read<MqttService>();
if (!mqttService.isConnected) {
print("--- OffersPage: MQTT not connected. Attempting to connect...");
final storage = const FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token != null && token.isNotEmpty) {
try {
await mqttService.connect(token);
if (mqttService.isConnected && mounted) {
_subscribeToUserOffersOnLoad();
}
} catch (e) {
print("❌ OffersPage: Error connecting to MQTT: $e");
}
}
} else {
print("--- OffersPage: MQTT already connected.");
_subscribeToUserOffersOnLoad();
}
}
@override
void dispose() {
_locationServiceSubscription?.cancel();
_mqttMessageSubscription?.cancel();
_locationTimer?.cancel();
_connectivitySubscription?.cancel();
super.dispose();
}
@ -69,8 +152,9 @@ class _OffersPageState extends State<OffersPage> {
void _initLocationListener() {
_checkInitialGpsStatus();
_locationServiceSubscription =
Geolocator.getServiceStatusStream().listen((status) {
_locationServiceSubscription = Geolocator.getServiceStatusStream().listen((
status,
) {
final isEnabled = status == ServiceStatus.enabled;
if (mounted && _isGpsEnabled != isEnabled) {
setState(() {
@ -87,7 +171,7 @@ class _OffersPageState extends State<OffersPage> {
}
});
}
Future<void> _checkInitialGpsStatus() async {
final status = await Geolocator.isLocationServiceEnabled();
if (mounted) {
@ -103,7 +187,7 @@ class _OffersPageState extends State<OffersPage> {
void _startSendingLocationUpdates() {
print("🚀 Starting periodic location updates.");
_locationTimer?.cancel();
_locationTimer = Timer.periodic(const Duration(seconds: 15), (timer) {
_locationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
_sendLocationUpdate();
});
_sendLocationUpdate();
@ -121,7 +205,7 @@ class _OffersPageState extends State<OffersPage> {
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
print("🚫 Location permission denied by user.");
@ -140,14 +224,9 @@ class _OffersPageState extends State<OffersPage> {
return;
}
final payload = {
"userID": userID,
"lat": 32.6685,
"lng": 51.6826,
};
final payload = {"userID": userID, "lat": 32.6685, "lng": 51.6826};
mqttService.publish("proxybuy/sendGps", payload);
} catch (e) {
print("❌ Error sending location update in OffersPage: $e");
}
@ -163,13 +242,22 @@ class _OffersPageState extends State<OffersPage> {
_mqttMessageSubscription = mqttService.messages.listen((message) {
final data = message['data'];
if (data != null && data is List) {
if (data == null) {
if (mounted) {
context.read<OffersBloc>().add(const OffersReceivedFromMqtt([]));
}
return;
}
if (data is List) {
try {
List<OfferModel> offers = data
.whereType<Map<String, dynamic>>()
.map((json) => OfferModel.fromJson(json))
.toList();
List<OfferModel> offers =
data
.whereType<Map<String, dynamic>>()
.map((json) => OfferModel.fromJson(json))
.toList();
if (mounted) {
context.read<OffersBloc>().add(OffersReceivedFromMqtt(offers));
}
@ -209,9 +297,14 @@ class _OffersPageState extends State<OffersPage> {
TextButton(
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
NotificationPreferencesPage.route(loadFavorites: true),
MaterialPageRoute(
builder:
(context) => const NotificationPreferencesPage(
loadFavoritesOnStart: true,
),
),
);
if (result == true && mounted) {
_loadPreferences();
}
@ -348,7 +441,10 @@ class _OffersPageState extends State<OffersPage> {
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildFavoriteCategoriesSection(), OffersView(isGpsEnabled: _isGpsEnabled)],
children: [
_buildFavoriteCategoriesSection(),
OffersView(isGpsEnabled: _isGpsEnabled),
],
),
),
),
@ -384,7 +480,7 @@ class OffersView extends StatelessWidget {
),
);
}
if (state is OffersLoadSuccess) {
if (state.offers.isEmpty) {
return const SizedBox(
@ -426,14 +522,14 @@ class OffersView extends StatelessWidget {
},
);
}
if (state is OffersLoadFailure) {
return SizedBox(
height: 200,
child: Center(child: Text("خطا در بارگذاری: ${state.error}")),
);
}
return const SizedBox.shrink();
},
);
@ -482,4 +578,4 @@ class OffersView extends StatelessWidget {
),
);
}
}
}

View File

@ -23,12 +23,14 @@ final List<OnboardingEntity> onboardingPages = [
OnboardingEntity(
imagePath: Assets.images.onboarding3.path,
title: ' پیشنهادهای لحظه‌ای، تجربه‌ای نو از خرید',
description: 'Proxibuy همیشه باهاته؛ چه در مسیر رفتن به خونه، چه در مسیر کافه مورد علاقه‌ات. همین الان خریدت رو هوشمندتر کن!',
description:
'Proxibuy همیشه باهاته؛ چه در مسیر رفتن به خونه، چه در مسیر کافه مورد علاقه‌ات. همین الان خریدت رو هوشمندتر کن!',
),
OnboardingEntity(
imagePath: Assets.images.onboarding4.path,
title: 'دنیایی از تخفیف‌های اطرافت رو کشف کن',
description: 'می‌صرفه یه اپ هوشمنده که کمکت می‌کنه وقتی توی شهر حرکت می‌کنی، از تخفیف‌های اطرافت باخبر شی و به صرفه‌تر خرید کنی.',
description:
'می‌صرفه یه اپ هوشمنده که کمکت می‌کنه وقتی توی شهر حرکت می‌کنی، از تخفیف‌های اطرافت باخبر شی و به صرفه‌تر خرید کنی.',
),
];
@ -69,13 +71,15 @@ class _OnboardingPageState extends State<OnboardingPage> {
);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => AuthBloc(),
child: const LoginPage(),
MaterialPageRoute(
builder:
(context) => BlocProvider(
// This creates a NEW AuthBloc
create: (context) => AuthBloc(),
child: const LoginPage(),
),
),
),
);
);
}
}
@ -120,42 +124,43 @@ class _OnboardingPageState extends State<OnboardingPage> {
left: 0,
right: 0,
child: SizedBox(
width: 80,
height: 80,
child: Stack(
alignment: Alignment.center,
width: 80,
height: 80,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 80,
height: 80,
child: CustomPaint(
painter: OnboardingIndicatorPainter(
pageCount: onboardingPages.length,
currentPage: _currentPage,
activeColor: AppColors.primary,
inactiveColor: AppColors.unselected,
),
),
),
Stack(
children: [
SizedBox(
width: 80,
height: 80,
child: CustomPaint(
painter: OnboardingIndicatorPainter(
pageCount: onboardingPages.length,
currentPage: _currentPage,
activeColor: AppColors.primary,
inactiveColor: AppColors.unselected,
),
Center(
child: GestureDetector(
onTap: _onArrowTap,
child: Assets.icons.ellipse1.svg(width: 60),
),
),
Stack(
children: [
Center(
child: GestureDetector(
onTap: _onArrowTap,
child: Assets.icons.ellipse1.svg(width: 60),
),
),
Center(
child: GestureDetector(
onTap: _onArrowTap,
child: Assets.icons.arrowRight2.svg(width: 45),
),
),
],
Center(
child: GestureDetector(
onTap: _onArrowTap,
child: Assets.icons.arrowRight2.svg(width: 45),
),
),
],
),
),),
],
),
),
),
Positioned(
bottom: 90,
left: 24,
@ -173,8 +178,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 18,
color:
Colors.white
color: Colors.white,
),
),
const SizedBox(height: 16),

View File

@ -1,19 +1,13 @@
// lib/presentation/pages/otp_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/svg.dart';
// import 'package:proxibuy/data/models/datasources/offer_data_source.dart'; // حذف شد
// import 'package:proxibuy/data/repositories/offer_repository.dart'; // حذف شد
import 'package:proxibuy/presentation/auth/bloc/auth_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/pages/user_info_page.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
import '../../core/config/app_colors.dart';
import '../../core/gen/assets.gen.dart';
import '../utils/otp_timer_helper.dart';
class OtpPage extends StatefulWidget {
final String phoneNumber;
final String phone;
@ -154,33 +148,13 @@ class _OtpPageState extends State<OtpPage> {
});
}
if (state is AuthNeedsInfo) {
// **تغییر اصلی در این قسمت است**
// دیگر نیازی به ساخت OfferRepository نیست
// ******** شروع تغییر ۲ ********
// BlocProvider.value حذف شد
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder:
(_) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<AuthBloc>(),
),
// OffersBloc دیگر به ریپازیتوری نیاز ندارد
BlocProvider<OffersBloc>(
create: (_) => OffersBloc(),
),
BlocProvider<ReservationCubit>(
create: (_) => ReservationCubit(),
),
BlocProvider<NotificationPreferencesBloc>(
create:
(_) => NotificationPreferencesBloc(),
),
],
child: const UserInfoPage(),
),
),
MaterialPageRoute(builder: (_) => const UserInfoPage()),
(route) => false,
);
// ******** پایان تغییر ۲ ********
}
},
builder: (context, state) {
@ -342,4 +316,4 @@ class _OtpPageState extends State<OtpPage> {
);
_otpTimer.resetTimer();
}
}
}

View File

@ -1,17 +1,18 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/svg.dart';
import 'package:maps_launcher/maps_launcher.dart';
import 'package:proxibuy/core/config/api_config.dart';
import 'package:proxibuy/core/config/app_colors.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/data/models/offer_model.dart';
import 'package:proxibuy/data/repositories/offer_repository.dart';
import 'package:proxibuy/presentation/pages/add_photo_screen.dart';
import 'package:proxibuy/presentation/pages/reservation_details_screen.dart';
import 'package:proxibuy/presentation/product_detail/bloc/product_detail_bloc.dart';
import 'package:proxibuy/presentation/product_detail/bloc/product_detail_event.dart';
import 'package:proxibuy/presentation/product_detail/bloc/product_detail_state.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
import 'package:proxibuy/presentation/widgets/comments_section.dart';
import 'package:slide_countdown/slide_countdown.dart';
@ -21,34 +22,138 @@ class ProductDetailPage extends StatelessWidget {
const ProductDetailPage({super.key, required this.offer});
Future<String> _generateQrToken(BuildContext context) async {
const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID');
if (userID == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("خطا: کاربر شناسایی نشد.")),
);
throw Exception("User ID not found");
}
final payload = {
'userID': userID,
'discountID': offer.id,
'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
const secretKey = 'your_super_secret_key_for_qr';
final jwt = JWT(payload);
final token = jwt.sign(SecretKey(secretKey));
return token;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
// ویجت نمایش جزئیات مستقیما ساخته میشود
ProductDetailView(offer: offer)
.animate()
.fadeIn(duration: 400.ms, curve: Curves.easeOut)
.slideY(
begin: 0.2,
duration: 400.ms,
curve: Curves.easeOut,
),
ProductDetailView(offer: offer),
Positioned(
bottom: 30,
left: 24,
right: 24,
// BlocBuilder حذف شد
child: ElevatedButton(
onPressed: () {
context.read<ReservationCubit>().reserveProduct(offer.id);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
ReservationConfirmationPage(offer: offer),
),
onPressed: () async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const Center(child: CircularProgressIndicator()),
);
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token == null) {
throw Exception("شما وارد حساب کاربری خود نشده‌اید.");
}
final dio = Dio();
final url = ApiConfig.baseUrl + ApiConfig.addReservation;
final data = {
'Discount': offer.id,
'Distance': offer.distanceInMeters.toString(),
};
final options = Options(
headers: {'Authorization': 'Bearer $token'},
);
debugPrint("----------- REQUEST-----------");
debugPrint("URL: POST $url");
debugPrint("Headers: ${options.headers}");
debugPrint("Body: $data");
debugPrint("-----------------------------");
final response =
await dio.post(url, data: data, options: options);
debugPrint("---------- RESPONSE-----------");
debugPrint("StatusCode: ${response.statusCode}");
debugPrint("Data: ${response.data}");
debugPrint("-----------------------------");
if (context.mounted) Navigator.of(context).pop();
if (response.statusCode == 200) {
final qrToken = await _generateQrToken(context);
context.read<ReservationCubit>().reserveProduct(offer.id);
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReservationConfirmationPage(
offer: offer,
qrCodeData: qrToken,
),
),
);
}
} else {
final errorMessage =
response.data['message'] ?? 'خطا در رزرو تخفیف';
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Colors.red),
);
}
}
} on DioException catch (e) {
if (context.mounted) Navigator.of(context).pop();
debugPrint("---------- ERROR-----------");
debugPrint("StatusCode: ${e.response?.statusCode}");
debugPrint("Data: ${e.response?.data}");
debugPrint("--------------------------");
final errorMessage = e.response?.data?['message'] ??
'خطای سرور هنگام رزرو. لطفاً دوباره تلاش کنید.';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: Colors.red),
);
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
debugPrint("---------- GENERAL ERROR -----------");
debugPrint(e.toString());
debugPrint("------------------------------------");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.confirm,
@ -73,7 +178,12 @@ class ProductDetailPage extends StatelessWidget {
),
],
),
).animate().fadeIn(delay: 200.ms).slideY(begin: 2),
).animate().slideY(
begin: 2,
delay: 400.ms,
duration: 600.ms,
curve: Curves.easeOutCubic,
),
),
],
),
@ -81,6 +191,7 @@ class ProductDetailPage extends StatelessWidget {
}
}
// ... بقیه کدهای این فایل بدون تغییر باقی میماند ...
class ProductDetailView extends StatefulWidget {
final OfferModel offer;
@ -120,9 +231,9 @@ class _ProductDetailViewState extends State<ProductDetailView> {
_buildMainImage(context),
const SizedBox(height: 52.5),
_buildProductInfo(),
const SizedBox(height: 100),
const SizedBox(height: 120),
],
),
).animate().fadeIn(duration: 600.ms, curve: Curves.easeOut),
Positioned(
top: 400 - 52.5,
left: 0,
@ -138,38 +249,39 @@ class _ProductDetailViewState extends State<ProductDetailView> {
Widget _buildMainImage(BuildContext context) {
return Stack(
children: [
SizedBox(
height: 400,
width: double.infinity,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: Image.network(
selectedImage,
key: ValueKey<String>(selectedImage),
fit: BoxFit.cover,
width: double.infinity,
height: 400,
loadingBuilder: (context, child, progress) {
return progress == null
? child
: const Center(child: CircularProgressIndicator());
Hero(
tag: 'offer_image_${widget.offer.id}',
child: SizedBox(
height: 400,
width: double.infinity,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: CachedNetworkImage(
imageUrl: selectedImage,
key: ValueKey<String>(selectedImage),
fit: BoxFit.cover,
width: double.infinity,
height: 400,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) =>
const Center(child: Icon(Icons.error)),
),
),
),
),
Positioned(
top: 40,
left: 16,
child:
GestureDetector(
child: SvgPicture.asset(Assets.icons.back.path),
onTap: () {
Navigator.pop(context);
},
).animate().fade(delay: 100.ms).scale(),
child: GestureDetector(
child: SvgPicture.asset(Assets.icons.back.path),
onTap: () {
Navigator.pop(context);
},
).animate().fade(delay: 200.ms).scale(),
),
],
);
@ -189,9 +301,10 @@ class _ProductDetailViewState extends State<ProductDetailView> {
itemBuilder: (context, index) {
final img = imageList[index];
if (img == _uploadKey) {
return _buildUploadButton().animate().fade().scale(
delay: (index * 50).ms,
);
return _buildUploadButton()
.animate()
.fade(delay: (index * 80).ms)
.scale(delay: (index * 80).ms);
}
final isSelected = selectedImage == img;
return _buildThumbnail(img, isSelected, index);
@ -203,32 +316,19 @@ class _ProductDetailViewState extends State<ProductDetailView> {
Widget _buildThumbnail(String img, bool isSelected, int index) {
const grayscaleMatrix = <double>[
0.2126,
0.7152,
0.0722,
0,
0,
0.2126,
0.7152,
0.0722,
0,
0,
0.2126,
0.7152,
0.0722,
0,
0,
0,
0,
0,
1,
0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
];
return GestureDetector(
onTap: () => setState(() => selectedImage = img),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
transform: Matrix4.identity()..scale(isSelected ? 1.0 : 0.9),
transformAlignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(
color: isSelected ? AppColors.selectedImg : Colors.transparent,
@ -237,7 +337,6 @@ class _ProductDetailViewState extends State<ProductDetailView> {
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
// ignore: deprecated_member_use
color: Colors.black.withOpacity(0.15),
blurRadius: 7,
spreadRadius: 0,
@ -250,46 +349,40 @@ class _ProductDetailViewState extends State<ProductDetailView> {
colorFilter: ColorFilter.matrix(
isSelected
? <double>[
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
1,
0,
]
1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 1, 0,
]
: grayscaleMatrix,
),
child: Image.network(img, width: 90, height: 90, fit: BoxFit.cover),
child: CachedNetworkImage(
imageUrl: img,
width: 90,
height: 90,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: Colors.grey[200]),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
),
),
),
).animate().fade().scale(delay: (index * 50).ms);
).animate().fade().scale(delay: (index * 80).ms);
}
Widget _buildUploadButton() {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddPhotoScreen(storeName: widget.offer.storeName, productId: widget.offer.id, offer: widget.offer,),
),
);
context,
MaterialPageRoute(
builder: (context) => AddPhotoScreen(
storeName: widget.offer.storeName,
productId: widget.offer.id,
offer: widget.offer,
),
),
);
},
child: Container(
width: 90,
@ -308,47 +401,51 @@ class _ProductDetailViewState extends State<ProductDetailView> {
}
Widget _buildProductInfo() {
final remainingDuration =
widget.offer.expiryTime.isAfter(DateTime.now())
? widget.offer.expiryTime.difference(DateTime.now())
: Duration.zero;
final remainingDuration = widget.offer.expiryTime.isAfter(DateTime.now())
? widget.offer.expiryTime.difference(DateTime.now())
: Duration.zero;
final animationList = <Widget>[
Row(
children: [
SvgPicture.asset(Assets.icons.shop.path, height: 30),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.offer.storeName,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0)
.copyWith(bottom: 5.0, top: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: [
SvgPicture.asset(Assets.icons.shop.path, height: 30),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.offer.storeName,
style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
const SizedBox(height: 16),
_buildInfoRow(icon: Assets.icons.location, text: widget.offer.address),
const SizedBox(height: 12),
ExpandableInfoRow(
icon: Assets.icons.clock,
titleWidget: Row(
children: [
Text(
widget.offer.isOpen ? "باز است" : "بسته است",
style: TextStyle(
fontSize: 16,
color:
widget.offer.isOpen
const SizedBox(height: 16),
_buildInfoRow(
icon: Assets.icons.location, text: widget.offer.address),
const SizedBox(height: 12),
ExpandableInfoRow(
icon: Assets.icons.clock,
titleWidget: Row(
children: [
Text(
widget.offer.isOpen ? "باز است" : "بسته است",
style: TextStyle(
fontSize: 16,
color: widget.offer.isOpen
? Colors.green.shade700
: Colors.red.shade700,
fontWeight: FontWeight.normal,
),
fontWeight: FontWeight.normal,
),
),
],
),
],
),
children:
widget.offer.workingHours
children: widget.offer.workingHours
.map(
(wh) => Padding(
padding: const EdgeInsets.only(
@ -368,235 +465,228 @@ class _ProductDetailViewState extends State<ProductDetailView> {
),
wh.isOpen
? Text(
wh.shifts
.map((s) => '${s.openAt} - ${s.closeAt}')
.join(' | '),
style: const TextStyle(
fontSize: 15,
color: AppColors.hint,
),
)
wh.shifts
.map((s) => '${s.openAt} - ${s.closeAt}')
.join(' | '),
style: const TextStyle(
fontSize: 15,
color: AppColors.hint,
),
)
: const Text(
'تعطیل',
style: TextStyle(fontSize: 15, color: Colors.red),
),
'تعطیل',
style:
TextStyle(fontSize: 15, color: Colors.red),
),
],
),
),
)
.toList(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InkWell(
onTap:
() => _launchMaps(
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InkWell(
onTap: () => _launchMaps(
widget.offer.latitude,
widget.offer.longitude,
widget.offer.storeName,
),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
SvgPicture.asset(
Assets.icons.map.path,
width: 25,
height: 25,
colorFilter: const ColorFilter.mode(
AppColors.button,
BlendMode.srcIn,
),
),
const SizedBox(width: 8),
const Text(
'مسیر فروشگاه روی نقشه',
style:
TextStyle(fontSize: 17, color: AppColors.button),
),
],
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SvgPicture.asset(
Assets.icons.map.path,
width: 25,
height: 25,
colorFilter: const ColorFilter.mode(
AppColors.button,
BlendMode.srcIn,
Container(
width: 60,
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: const BoxDecoration(
color: AppColors.selectedImg,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.offer.rating.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
const SizedBox(width: 2),
SvgPicture.asset(
Assets.icons.star.path,
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
),
],
),
),
const SizedBox(width: 8),
const Text(
'مسیر فروشگاه روی نقشه',
style: TextStyle(fontSize: 17, color: AppColors.button),
Container(
height: 30,
width: 60,
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: Center(
child: Text(
'${widget.offer.ratingCount} نفر',
style: const TextStyle(
color: Colors.black,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
const SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 60,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: const BoxDecoration(
color: AppColors.selectedImg,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
decoration: BoxDecoration(
color: AppColors.singleOfferType,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.offer.rating.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
const SizedBox(width: 2),
SvgPicture.asset(
Assets.icons.star.path,
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
),
],
child: Text(
"تخفیف ${widget.offer.discountType}",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
),
Container(
height: 30,
width: 60,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)',
style: const TextStyle(
fontSize: 16,
color: AppColors.singleOfferType,
fontWeight: FontWeight.normal,
),
),
const SizedBox(width: 8),
Text(
widget.offer.originalPrice.toStringAsFixed(0),
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
decoration: TextDecoration.lineThrough,
),
),
],
),
),
child: Center(
child: Text(
'${widget.offer.ratingCount} نفر',
const SizedBox(height: 1),
Text(
'${widget.offer.finalPrice.toStringAsFixed(0)} تومان',
style: const TextStyle(
color: Colors.black,
fontSize: 11,
color: AppColors.singleOfferType,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
],
),
const SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
decoration: BoxDecoration(
color: AppColors.singleOfferType,
borderRadius: BorderRadius.circular(20),
),
child: Text(
"تخفیف ${widget.offer.discountType}",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'(${widget.offer.discount})',
style: const TextStyle(
fontSize: 16,
color: AppColors.singleOfferType,
fontWeight: FontWeight.normal,
),
),
const SizedBox(width: 8),
Text(
widget.offer.originalPrice.toStringAsFixed(0),
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
decoration: TextDecoration.lineThrough,
),
),
],
),
const SizedBox(height: 1),
Text(
'${widget.offer.finalPrice.toStringAsFixed(0)} تومان',
style: const TextStyle(
color: AppColors.singleOfferType,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
const SizedBox(height: 24),
_buildInfoRow(
icon: Assets.icons.timerPause,
text: "مهلت استفاده از تخفیف",
),
const SizedBox(height: 10),
if (remainingDuration > Duration.zero)
Column(
children: [
Localizations.override(
context: context,
locale: const Locale('en'),
child: SlideCountdown(
duration: remainingDuration,
slideDirection: SlideDirection.up,
separator: ':',
style: const TextStyle(
fontSize: 70,
fontWeight: FontWeight.bold,
color: AppColors.countdown,
const SizedBox(height: 24),
_buildInfoRow(
icon: Assets.icons.timerPause,
text: "مهلت استفاده از تخفیف",
),
const SizedBox(height: 10),
if (remainingDuration > Duration.zero)
Column(
children: [
Localizations.override(
context: context,
locale: const Locale('en'),
child: SlideCountdown(
duration: remainingDuration,
slideDirection: SlideDirection.up,
separator: ':',
style: const TextStyle(
fontSize: 50,
fontWeight: FontWeight.bold,
color: AppColors.countdown,
),
separatorStyle: const TextStyle(
fontSize: 40,
color: AppColors.countdown,
),
decoration: const BoxDecoration(color: Colors.white),
shouldShowDays: (d) => d.inDays > 0,
shouldShowHours: (d) => d.inHours > 0,
shouldShowMinutes: (d) => d.inSeconds > 0,
),
),
separatorStyle: const TextStyle(
fontSize: 40,
color: AppColors.countdown,
),
decoration: const BoxDecoration(color: Colors.white),
shouldShowDays: (d) => d.inDays > 0,
shouldShowHours: (d) => d.inHours > 0,
shouldShowMinutes:
(d) =>
d.inSeconds >
0,
),
const SizedBox(height: 4),
_buildTimerLabels(remainingDuration),
],
),
const SizedBox(height: 24),
_buildFeaturesSection(),
const SizedBox(height: 24),
_buildDiscountTypeSection(),
const SizedBox(height: 24),
CommentsSection(comments: widget.offer.comments),
].animate(interval: 80.ms).slideX(begin: -0.05).fadeIn(
duration: 400.ms,
curve: Curves.easeOut,
),
const SizedBox(height: 4),
_buildTimerLabels(remainingDuration),
],
),
const SizedBox(height: 24),
_buildFeaturesSection(),
const SizedBox(height: 24),
_buildDiscountTypeSection(),
const SizedBox(height: 24),
CommentsSection(comments: widget.offer.comments),
].animate(interval: 80.ms).fade(duration: 300.ms).slideX(begin: -0.1);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
).copyWith(bottom: 24.0, top: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: animationList
.animate(interval: 80.ms)
.fade(duration: 300.ms)
.slideX(begin: -0.1),
),
);
}
@ -684,7 +774,7 @@ class _ProductDetailViewState extends State<ProductDetailView> {
width: 22,
height: 22,
colorFilter: const ColorFilter.mode(
AppColors.confirm,
AppColors.hint,
BlendMode.srcIn,
),
),
@ -741,7 +831,7 @@ class _ProductDetailViewState extends State<ProductDetailView> {
const SizedBox(width: 10),
Expanded(
child: Text(
"${widget.offer.discount} تخفیف ${info.name}",
'${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}% تخفیف ${widget.offer.discountType}',
style: const TextStyle(
fontSize: 16,
height: 1.4,
@ -760,12 +850,12 @@ class _ProductDetailViewState extends State<ProductDetailView> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: SvgPicture.asset(Assets.icons.warning2.path, height: 24),
),
child:
SvgPicture.asset(Assets.icons.warning2.path, height: 24)),
const SizedBox(width: 10),
Expanded(
child: Text(
info.description,
widget.offer.discountInfo,
style: const TextStyle(fontSize: 14, color: AppColors.hint),
),
),
@ -858,13 +948,12 @@ class _ExpandableInfoRowState extends State<ExpandableInfoRow> {
AnimatedCrossFade(
firstChild: Container(),
secondChild: Column(children: widget.children),
crossFadeState:
_isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
crossFadeState: _isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300),
),
],
);
}
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
@ -11,27 +12,48 @@ import 'package:flutter_animate/flutter_animate.dart';
class ReservationConfirmationPage extends StatefulWidget {
final OfferModel offer;
final String qrCodeData;
const ReservationConfirmationPage({super.key, required this.offer});
const ReservationConfirmationPage({
super.key,
required this.offer,
required this.qrCodeData,
});
@override
State<ReservationConfirmationPage> createState() =>
_ReservationConfirmationPageState();
}
class _ReservationConfirmationPageState extends State<ReservationConfirmationPage> {
class _ReservationConfirmationPageState
extends State<ReservationConfirmationPage> {
Timer? _timer;
Duration _remaining = Duration.zero;
final AudioPlayer _audioPlayer = AudioPlayer();
@override
void initState() {
super.initState();
_playSound();
// ******** پایان تغییر اصلی ********
_calculateRemainingTime();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_calculateRemainingTime();
});
}
// متد جدید برای پخش صدا
void _playSound() async {
try {
// فایل صدایی که در پوشه assets قرار دادهاید
await _audioPlayer.play(AssetSource('sounds/positive-notification-alert-351299.mp3'));
} catch (e) {
debugPrint("Error playing sound: $e");
}
}
void _calculateRemainingTime() {
final now = DateTime.now();
if (widget.offer.expiryTime.isAfter(now)) {
@ -53,9 +75,11 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
@override
void dispose() {
_timer?.cancel();
_audioPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Directionality(
@ -65,39 +89,48 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
appBar: _buildCustomAppBar(context),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 32.0),
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 32.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'تخفیف ${widget.offer.discountType} رزرو شد!',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
).animate().fadeIn(delay: 300.ms, duration: 500.ms).slideY(begin: -0.2, end: 0),
'تخفیف ${widget.offer.discountType} رزرو شد!',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
)
.animate()
.fadeIn(delay: 300.ms, duration: 500.ms)
.slideY(begin: -0.2, end: 0),
const SizedBox(height: 8),
const Divider(thickness: 1.5)
.animate()
.fadeIn(delay: 400.ms)
.scaleX(begin: 0, duration: 600.ms, curve: Curves.easeInOut),
.scaleX(
begin: 0,
duration: 600.ms,
curve: Curves.easeInOut,
),
const SizedBox(height: 18),
_buildOfferDetailsCard()
.animate()
.fadeIn(delay: 600.ms, duration: 500.ms)
.slideX(begin: 0.5, end: 0, curve: Curves.easeOutCubic),
const SizedBox(height: 18),
_buildTimerCard()
.animate()
.fadeIn(delay: 800.ms, duration: 500.ms)
.scale(begin: const Offset(0.8, 0.8), curve: Curves.easeOutBack),
.scale(
begin: const Offset(0.8, 0.8),
curve: Curves.easeOutBack,
),
const SizedBox(height: 18),
_buildQrCodeCard()
_buildQrCodeCard() // نمایش QR Code با استفاده از توکن
.animate()
.fadeIn(delay: 1000.ms, duration: 500.ms)
.flipV(begin: -0.5, end: 0, curve: Curves.easeOut),
@ -120,7 +153,6 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
),
boxShadow: [
BoxShadow(
// ignore: deprecated_member_use
color: Colors.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
@ -132,7 +164,7 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
SizedBox(height: 15),
const SizedBox(height: 15),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@ -198,7 +230,7 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
SvgPicture.asset(Assets.icons.ticketDiscount.path),
const SizedBox(width: 6),
Text(
'(${widget.offer.discount})',
'(${(100-widget.offer.finalPrice/widget.offer.originalPrice*100).toInt()}%)',
style: const TextStyle(
fontSize: 16,
color: AppColors.singleOfferType,
@ -219,9 +251,12 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
const SizedBox(height: 10),
Row(
children: [
// ignore: deprecated_member_use
SvgPicture.asset(Assets.icons.cardPos.path,height: 22,color: Color.fromARGB(255, 157, 157, 155),),
SizedBox(width: 6,),
SvgPicture.asset(
Assets.icons.cardPos.path,
height: 22,
color: const Color.fromARGB(255, 157, 157, 155),
),
const SizedBox(width: 6),
Text(
'${widget.offer.finalPrice.toStringAsFixed(0)} تومان',
style: const TextStyle(
@ -250,7 +285,7 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
),
);
}
String days = _remaining.inDays.toString();
String hours = (_remaining.inHours % 24).toString();
String minutes = (_remaining.inMinutes % 60).toString();
@ -259,7 +294,7 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
return Container(
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: Color.fromARGB(255, 246, 246, 246),
color: const Color.fromARGB(255, 246, 246, 246),
borderRadius: BorderRadius.circular(16),
),
child: Column(
@ -267,76 +302,90 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Column(
const Column(
children: [
Text("مدت",style: TextStyle(color: AppColors.expiryReserve,fontSize: 15),),
SizedBox(height: 7,),
Text("اعتبار",style: TextStyle(color: AppColors.expiryReserve,fontSize: 15),),
Text(
"مدت",
style: TextStyle(
color: AppColors.expiryReserve,
fontSize: 15,
),
),
SizedBox(height: 7),
Text(
"اعتبار",
style: TextStyle(
color: AppColors.expiryReserve,
fontSize: 15,
),
),
],
),
SizedBox(width: 25,),
const SizedBox(width: 15),
_buildTimeBlock(seconds, 'ثانیه'),
SizedBox(width: 20,),
const SizedBox(width: 10),
_buildTimeBlock(minutes, 'دقیقه'),
SizedBox(width: 20,),
const SizedBox(width: 10),
_buildTimeBlock(hours, 'ساعت'),
SizedBox(width: 20,),
if (_remaining.inDays > 0) _buildTimeBlock(days, 'روز'),
if (_remaining.inDays > 0) ...[
const SizedBox(width: 10),
_buildTimeBlock(days, 'روز'),
],
],
),
SizedBox(height: 20 ,),
Text("لطفا QR Code زیر رو به فروشنده نشون بده.",style: TextStyle(fontSize: 15),)
const SizedBox(height: 20),
const Text(
"لطفا QR Code زیر رو به فروشنده نشون بده.",
style: TextStyle(fontSize: 15),
),
],
),
);
}
Widget _buildTimeBlock(String value, String label) {
return Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: AppColors.countdownBorderRserve, width: 1.5),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: const TextStyle(
fontSize: 20,
fontFamily: 'Dana',
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontFamily: 'Dana',
color: Colors.black,
),
),
],
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 5,
),
decoration: BoxDecoration(
border: Border.all(color: AppColors.countdownBorderRserve, width: 1.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: const TextStyle(
fontSize: 20, // فونت کمی کوچکتر شد
fontFamily: 'Dana',
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
),
],
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontFamily: 'Dana',
color: Colors.black,
),
),
],
),
);
}
Widget _buildQrCodeCard() {
return Center(
child: Container(
width: 500,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Color.fromARGB(255, 246, 246, 246),
color: const Color.fromARGB(255, 246, 246, 246),
borderRadius: BorderRadius.circular(16),
),
child: Column(
@ -344,16 +393,15 @@ class _ReservationConfirmationPageState extends State<ReservationConfirmationPag
Padding(
padding: const EdgeInsets.all(8.0),
child: QrImageView(
data: widget.offer.qrCodeData,
data: widget.qrCodeData,
version: QrVersions.auto,
size: 280.0,
),
),
SizedBox(height: 10,),
Text(widget.offer.qrCodeData,style: TextStyle(fontWeight: FontWeight.bold,fontSize: 20),)
const SizedBox(height: 10),
],
),
),
);
}
}
}

View File

@ -1,13 +1,11 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; //
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/svg.dart';
import 'package:proxibuy/core/config/api_config.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:proxibuy/data/models/offer_model.dart';
import 'package:proxibuy/data/repositories/offer_repository.dart';
import 'package:proxibuy/presentation/offer/bloc/offer_bloc.dart';
import 'package:proxibuy/presentation/offer/bloc/offer_state.dart';
import 'package:proxibuy/presentation/reservation/cubit/reservation_cubit.dart';
import 'package:proxibuy/presentation/widgets/reserved_list_item_card.dart';
class ReservedListPage extends StatefulWidget {
@ -18,30 +16,55 @@ class ReservedListPage extends StatefulWidget {
}
class _ReservedListPageState extends State<ReservedListPage> {
late final List<String> _reservedIds;
// دیگر نیازی به Future نیست
List<OfferModel> _reservedOffers = [];
late Future<List<OfferModel>> _reservedOffersFuture;
@override
void initState() {
super.initState();
_reservedIds = context.read<ReservationCubit>().state.reservedProductIds;
// اطلاعات مستقیما از BLoC خوانده میشود
_fetchReservedOffersFromBloc();
_reservedOffersFuture = _fetchReservedOffers();
}
void _fetchReservedOffersFromBloc() {
final offersState = context.read<OffersBloc>().state;
// بررسی میکند که آیا پیشنهادها قبلا بارگذاری شدهاند یا خیر
if (offersState is OffersLoadSuccess) {
final allOffers = offersState.offers;
if (mounted) {
setState(() {
_reservedOffers = allOffers
.where((offer) => _reservedIds.contains(offer.id))
.toList();
});
Future<List<OfferModel>> _fetchReservedOffers() async {
try {
const storage = FlutterSecureStorage();
final token = await storage.read(key: 'accessToken');
if (token == null) {
throw Exception('شما وارد حساب کاربری خود نشده‌اید.');
}
final dio = Dio();
final response = await dio.get(
ApiConfig.baseUrl + ApiConfig.getReservations,
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (response.statusCode == 200) {
final List<dynamic> reserves = response.data['reserves'];
// ******** شروع تغییر ۲ ********
// تبدیل ساختار جدید JSON به فرمت مورد انتظار OfferModel
return reserves.map((reserveData) {
final discountData = reserveData['Discount'] as Map<String, dynamic>;
final shopData = reserveData['Shop'] as Map<String, dynamic>;
// ترکیب اطلاعات برای سازگاری با مدل
final fullOfferData = {
...discountData,
'shopData': shopData,
'Distance': reserveData['Distance'], // پاس دادن فاصله به مدل
};
return OfferModel.fromJson(fullOfferData);
}).toList();
// ******** پایان تغییر ۲ ********
} else {
throw Exception('خطا در دریافت اطلاعات از سرور');
}
} on DioException catch (e) {
final errorMessage = e.response?.data?['message'] ?? 'خطای شبکه';
throw Exception(errorMessage);
} catch (e) {
throw Exception('خطایی رخ داد: $e');
}
}
@ -51,17 +74,36 @@ class _ReservedListPageState extends State<ReservedListPage> {
textDirection: TextDirection.rtl,
child: Scaffold(
appBar: _buildCustomAppBar(context),
// FutureBuilder با یک ویجت ساده جایگزین شد
body: _reservedOffers.isEmpty
? const Center(
body: FutureBuilder<List<OfferModel>>(
future: _reservedOffersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('خطا: ${snapshot.error}', textAlign: TextAlign.center),
),
);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(
child: Text('هیچ آیتم رزرو شده‌ای یافت نشد.'),
)
: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _reservedOffers.length,
itemBuilder: (context, index) {
final offer = _reservedOffers[index];
return Padding(
);
}
final reservedOffers = snapshot.data!;
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: reservedOffers.length,
itemBuilder: (context, index) {
final offer = reservedOffers[index];
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
@ -79,9 +121,7 @@ class _ReservedListPageState extends State<ReservedListPage> {
const Divider(thickness: 1.5),
const SizedBox(height: 2),
ReservedListItemCard(offer: offer),
SizedBox(
height: 15,
)
const SizedBox(height: 15),
],
),
).animate().fadeIn(delay: (100 * index).ms).slideY(
@ -90,9 +130,11 @@ class _ReservedListPageState extends State<ReservedListPage> {
curve: Curves.easeOutCubic,
);
},
)
) ); }
);
},
),
),
);
}
PreferredSizeWidget _buildCustomAppBar(BuildContext context) {
@ -106,7 +148,6 @@ class _ReservedListPageState extends State<ReservedListPage> {
),
boxShadow: [
BoxShadow(
// ignore: deprecated_member_use
color: Colors.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
@ -118,7 +159,7 @@ class _ReservedListPageState extends State<ReservedListPage> {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
SizedBox(height: 15),
const SizedBox(height: 15),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@ -147,3 +188,4 @@ class _ReservedListPageState extends State<ReservedListPage> {
),
);
}
}

View File

@ -1,3 +1,5 @@
// lib/presentation/pages/user_info_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:proxibuy/presentation/auth/bloc/auth_bloc.dart';
@ -22,6 +24,18 @@ class _UserInfoPageState extends State<UserInfoPage> {
super.dispose();
}
// ******** شروع تغییر ۱ ********
// متد ناوبری را ساده میکنیم.
// این متد دیگر Provider جدیدی نمیسازد و از Provider های سراسری استفاده میکند.
void _navigateToNextPage() {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => const NotificationPreferencesPage(),
),
);
}
// ******** پایان تغییر ۱ ********
Widget _buildGenderRadio(String title, String value) {
return InkWell(
onTap: () => setState(() => _selectedGender = value),
@ -69,7 +83,6 @@ class _UserInfoPageState extends State<UserInfoPage> {
Positioned.fill(
child: Image.asset(Assets.images.userinfo.path, fit: BoxFit.cover),
),
DraggableScrollableSheet(
initialChildSize: 0.50,
minChildSize: 0.50,
@ -91,7 +104,6 @@ class _UserInfoPageState extends State<UserInfoPage> {
width: 50,
height: 5,
decoration: BoxDecoration(
// ignore: deprecated_member_use
color: Colors.grey.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
@ -115,7 +127,6 @@ class _UserInfoPageState extends State<UserInfoPage> {
),
),
const SizedBox(height: 24),
Text(
"جنسیت",
style: textTheme.titleMedium?.copyWith(
@ -133,9 +144,13 @@ class _UserInfoPageState extends State<UserInfoPage> {
],
),
const SizedBox(height: 55),
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
// ******** لاگ: state جدید دریافت شد ********
debugPrint(
"UserInfoPage: 🟠 Listener یک State جدید دریافت کرد: ${state.runtimeType}",
);
if (state is AuthFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -144,10 +159,16 @@ class _UserInfoPageState extends State<UserInfoPage> {
),
);
}
if (state is AuthSuccess) {
// ******** لاگ: state موفقیت آمیز بود ********
debugPrint(
"UserInfoPage: ✅ State از نوع AuthSuccess است. فراخوانی _navigateToNextPage...",
);
_navigateToNextPage();
}
},
builder: (context, state) {
final isLoading = state is AuthLoading;
return Column(
children: [
SizedBox(
@ -173,14 +194,9 @@ class _UserInfoPageState extends State<UserInfoPage> {
},
),
const SizedBox(height: 9),
Center(
child: TextButton(
onPressed: () {
Navigator.of(context).pushReplacement(
NotificationPreferencesPage.route(),
);
},
onPressed: _navigateToNextPage,
child: const Text(
"رد شدن",
style: TextStyle(color: Colors.black),

View File

@ -1,3 +1,5 @@
// lib/presentation/reservation/cubit/reservation_cubit.dart
// ignore: depend_on_referenced_packages
import 'package:bloc/bloc.dart';
@ -13,6 +15,11 @@ class ReservationCubit extends Cubit<ReservationState> {
emit(state.copyWith(reservedProductIds: updatedList));
}
// متد جدید برای تنظیم کردن همه شناسههای رزرو شده
void setReservedIds(List<String> productIds) {
emit(state.copyWith(reservedProductIds: productIds));
}
bool isProductReserved(String productId) {
return state.reservedProductIds.contains(productId);
}

View File

@ -1,8 +1,8 @@
// lib/presentation/widgets/reserved_list_item_card.dart
import 'dart:async';
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:proxibuy/core/gen/assets.gen.dart';
import 'package:qr_flutter/qr_flutter.dart';
@ -23,6 +23,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
bool _isExpanded = false;
Timer? _timer;
Duration _remaining = Duration.zero;
Future<String>? _qrTokenFuture;
@override
void initState() {
@ -33,6 +34,38 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
});
}
Future<String> _generateQrToken() async {
const storage = FlutterSecureStorage();
final userID = await storage.read(key: 'userID');
if (userID == null) {
throw Exception("User ID not found");
}
final payload = {
'userID': userID,
'discountID': widget.offer.id,
'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
};
const secretKey = 'your_super_secret_key_for_qr';
final jwt = JWT(payload);
final token = jwt.sign(SecretKey(secretKey));
return token;
}
void _toggleExpansion() {
setState(() {
_isExpanded = !_isExpanded;
// توکن فقط زمانی ساخته میشود که کاربر پنل را باز میکند
if (_isExpanded && _qrTokenFuture == null) {
_qrTokenFuture = _generateQrToken();
}
});
}
void _calculateRemainingTime() {
final now = DateTime.now();
if (widget.offer.expiryTime.isAfter(now)) {
@ -55,18 +88,8 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
super.dispose();
}
// ignore: unused_element
String _formatDuration(Duration duration) {
if (duration.inSeconds <= 0) return "پایان یافته";
final hours = duration.inHours.toString().padLeft(2, '0');
final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0');
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Card(
@ -194,7 +217,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
),
SizedBox(width: 10),
TextButton(
onPressed: () => setState(() => _isExpanded = !_isExpanded),
onPressed: _toggleExpansion,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -314,7 +337,7 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'(${widget.offer.discount})',
'(${(100 - widget.offer.finalPrice / widget.offer.originalPrice * 100).toInt()}%)',
style: const TextStyle(
fontSize: 14,
color: AppColors.singleOfferType,
@ -345,34 +368,37 @@ class _ReservedListItemCardState extends State<ReservedListItemCard> {
),
],
),
SizedBox(
height: 20,
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Color.fromARGB(255, 246, 246, 246),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(15.0),
child: QrImageView(
data: widget.offer.qrCodeData,
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,
),
),
SizedBox(height: 10),
Text(
widget.offer.qrCodeData,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
],
);
}
return const SizedBox.shrink();
},
),
),
],

View File

@ -28,7 +28,7 @@ class MqttService {
client = MqttServerClient.withPort(server, clientId, port);
client.logging(on: true);
client.keepAlivePeriod = 60;
client.autoReconnect = false;
client.autoReconnect = true; // فعالسازی اتصال مجدد خودکار
client.setProtocolV311();
debugPrint('--- [MQTT] Attempting to connect...');
@ -64,9 +64,20 @@ class MqttService {
});
};
// این callback زمانی فراخوانی میشود که اتصال قطع شود
client.onDisconnected = () {
debugPrint('❌ [MQTT] Disconnected.');
};
// این callback زمانی فراخوانی میشود که کلاینت در حال تلاش برای اتصال مجدد خودکار است
client.onAutoReconnect = () {
debugPrint('↪️ [MQTT] Auto-reconnecting...');
};
// این callback زمانی فراخوانی میشود که اتصال مجدد خودکار موفقیتآمیز باشد
client.onAutoReconnected = () {
debugPrint('✅ [MQTT] Auto-reconnected successfully.');
};
client.onSubscribed = (String topic) {
debugPrint('✅ [MQTT] Subscribed to topic: $topic');

View File

@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_localization/flutter_localization_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
@ -13,6 +14,9 @@
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
flutter_localization
flutter_secure_storage_linux

View File

@ -5,7 +5,9 @@
import FlutterMacOS
import Foundation
import audioplayers_darwin
import cloud_firestore
import connectivity_plus
import file_selector_macos
import firebase_auth
import firebase_core
@ -20,7 +22,9 @@ import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))

View File

@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.59"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
analyzer:
dependency: transitive
description:
@ -49,6 +57,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.12.0"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: e653f162ddfcec1da2040ba2d8553fff1662b5c2a5c636f4c21a3b11bee497de
url: "https://pub.dev"
source: hosted
version: "6.5.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
bloc:
dependency: transitive
description:
@ -225,6 +289,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "051849e2bd7c7b3bc5844ea0d096609ddc3a859890ec3a9ac4a65a2620cc1f99"
url: "https://pub.dev"
source: hosted
version: "6.1.4"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
@ -265,6 +345,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_jsonwebtoken:
dependency: "direct main"
description:
name: dart_jsonwebtoken
sha256: "21ce9f8a8712f741e8d6876a9c82c0f8a257fe928c4378a91d8527b92a3fd413"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
dart_style:
dependency: transitive
description:
@ -281,6 +369,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio:
dependency: "direct main"
description:
@ -297,6 +393,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
ed25519_edwards:
dependency: transitive
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
equatable:
dependency: "direct main"
description:
@ -917,6 +1021,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
octo_image:
dependency: transitive
description:
@ -1077,6 +1189,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
pool:
dependency: transitive
description:

View File

@ -58,6 +58,9 @@ dependencies:
cloud_firestore: ^5.6.12
firebase_storage: ^12.4.10
flutter_secure_storage: ^9.2.4
connectivity_plus: ^6.1.4
dart_jsonwebtoken: ^3.2.0
audioplayers: ^6.5.0
dev_dependencies:
flutter_test:
@ -87,6 +90,7 @@ flutter:
assets:
- assets/images/
- assets/icons/
- assets/sounds/
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see

View File

@ -6,7 +6,9 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_auth/firebase_auth_plugin_c_api.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
@ -19,8 +21,12 @@
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
CloudFirestorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CloudFirestorePluginCApi"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseAuthPluginCApiRegisterWithRegistrar(

View File

@ -3,7 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
cloud_firestore
connectivity_plus
file_selector_windows
firebase_auth
firebase_core