add product

This commit is contained in:
mohamadmahdi jebeli 2025-07-09 16:56:26 +03:30
parent 0c53bfdbbb
commit 9ea1edf9b8
22 changed files with 868 additions and 86 deletions

View File

@ -0,0 +1,38 @@
class ApiConfig {
// Private constructor to prevent instantiation
ApiConfig._();
// Base URL for the API
static const String baseUrl = 'https://fartak.liara.run';
// ========== Auth Endpoints ==========
/// Endpoint to send OTP code to the user.
/// Method: POST
/// Body: {'Phone': phoneNumber, 'Code': countryCode}
static const String sendOtp = '$baseUrl/login/sendcode';
/// Endpoint to verify the OTP code.
/// Method: POST
/// Body: {'Phone': phoneNumber, 'Code': countryCode, 'OTP': otp}
static const String verifyOtp = '$baseUrl/login/getcode';
/// Endpoint to check if the user has a registered shop.
/// Method: GET
/// Headers: {'Authorization': 'Bearer <token>'}
static const String checkShopStatus = '$baseUrl/shop/get';
// ========== Store Endpoints ==========
/// Endpoint to add a new store.
/// Method: POST
/// Body: FormData
/// Headers: {'Authorization': 'Bearer <token>'}
static const String addStore = '$baseUrl/shop/add';
// ========== Discount Endpoints ==========
/// Endpoint to add a new discount.
/// Method: POST
/// Body: FormData
/// Headers: {'Authorization': 'Bearer <token>'}
static const String addDiscount = '$baseUrl/discount/add';
static const String getDiscounts = '$baseUrl/discount/get';
}

View File

@ -17,4 +17,5 @@ class AppColors {
static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251); static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251);
static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28); static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28);
static const Color uploadElevated = Color.fromARGB(255, 233, 245, 254); static const Color uploadElevated = Color.fromARGB(255, 233, 245, 254);
static const Color secTitle = Color.fromARGB(255, 95, 95, 95);
} }

View File

@ -0,0 +1,47 @@
class DiscountEntity {
final String id;
final String name;
final String shopName;
final List<String> images;
final String type;
final String description;
final double price;
final double nPrice;
final DateTime? endDate;
DiscountEntity({
required this.id,
required this.name,
required this.shopName,
required this.images,
required this.type,
required this.description,
required this.price,
required this.nPrice,
required this.endDate,
});
factory DiscountEntity.fromJson(Map<String, dynamic> json) {
// A helper function to safely extract lists
List<String> _parseImages(dynamic imageList) {
if (imageList is List) {
return imageList.map((img) => (img['Url'] ?? '') as String).toList();
}
return [];
}
return DiscountEntity(
id: json['_id'] ?? '',
name: json['Name'] ?? 'بدون نام',
// Safely access nested properties
shopName: (json['Shop'] != null ? json['Shop']['Name'] : 'فروشگاه') ?? 'فروشگاه',
images: _parseImages(json['Images']),
type: (json['Type'] != null ? json['Type']['Description'] : 'عمومی') ?? 'عمومی',
description: json['Description'] ?? '',
price: (json['Price'] as num? ?? 0).toDouble(),
nPrice: (json['NPrice'] as num? ?? 0).toDouble(),
// Handle potential null or invalid date
endDate: json['EndDate'] != null ? DateTime.tryParse(json['EndDate']) : null,
);
}
}

View File

@ -0,0 +1,6 @@
class DiscountTypeEntity {
final String id;
final String name;
DiscountTypeEntity({required this.id, required this.name});
}

View File

@ -1,6 +1,7 @@
import 'package:business_panel/core/config/app_colors.dart'; import 'package:business_panel/core/config/app_colors.dart';
import 'package:business_panel/presentation/auth/bloc/auth_bloc.dart'; import 'package:business_panel/presentation/auth/bloc/auth_bloc.dart';
import 'package:business_panel/presentation/pages/onboarding_page.dart'; import 'package:business_panel/presentation/pages/onboarding_page.dart';
import 'package:business_panel/presentation/pages/splash_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@ -99,8 +100,9 @@ class MyApp extends StatelessWidget {
), ),
), ),
), ),
home: const OnboardingPage(), home: const SplashPage(),
), ),
); );
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:business_panel/core/config/api_config.dart'; // <-- این خط را اضافه کنید
import 'package:business_panel/core/services/token_storage_service.dart'; import 'package:business_panel/core/services/token_storage_service.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:business_panel/core/utils/logging_interceptor.dart'; import 'package:business_panel/core/utils/logging_interceptor.dart';
@ -6,40 +7,74 @@ part 'auth_event.dart';
part 'auth_state.dart'; part 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> { class AuthBloc extends Bloc<AuthEvent, AuthState> {
late final Dio _dio; late final Dio _dio;
final TokenStorageService _tokenStorage = TokenStorageService(); final TokenStorageService _tokenStorage = TokenStorageService();
AuthBloc() : super(AuthInitial()) { AuthBloc() : super(AuthInitial()) {
_dio = Dio(); _dio = Dio();
_dio.interceptors.add(LoggingInterceptor()); _dio.interceptors.add(LoggingInterceptor());
on<SendOTPEvent>((event, emit) async { on<CheckAuthStatus>((event, emit) async {
emit(AuthLoading()); emit(AuthLoading());
final token = await _tokenStorage.getAccessToken();
emit(AuthChecked(token != null && token.isNotEmpty));
});
on<CheckShopStatus>((event, emit) async {
emit(AuthLoading()); emit(AuthLoading());
try { try {
final token = await _tokenStorage.getAccessToken();
if (token == null || token.isEmpty) {
emit(AuthFailure("Token not found. Please log in again."));
return;
}
// Use the ApiConfig class
await _dio.get(
ApiConfig.checkShopStatus, // <-- تغییر در این خط
options: Options(
headers: {'Authorization': 'Bearer $token'},
),
);
emit(ShopExists());
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
emit(NoShop());
} else {
emit(AuthFailure(e.response?.data?['message'] ?? 'An error occurred while checking shop status.'));
}
} catch (e) {
emit(AuthFailure('An unexpected error occurred: ${e.toString()}'));
}
});
on<SendOTPEvent>((event, emit) async {
emit(AuthLoading());
try {
// Use the ApiConfig class
final response = await _dio.post( final response = await _dio.post(
'https://fartak.liara.run/login/sendcode', ApiConfig.sendOtp, // <-- تغییر در این خط
data: { data: {'Phone': event.phoneNumber, 'Code': event.countryCode},
'Phone': event.phoneNumber,
'Code': event.countryCode,
},
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
emit(AuthCodeSentSuccess()); emit(AuthCodeSentSuccess());
} else { } else {
emit(AuthFailure(response.data['message'] ?? 'خطایی رخ داد')); emit(AuthFailure(response.data['message'] ?? 'An error occurred.'));
} }
} on DioException catch (e) { } on DioException catch (e) {
emit(AuthFailure(e.response?.data['message'] ?? 'خطای شبکه')); emit(AuthFailure(e.response?.data['message'] ?? 'Network error.'));
} }
}); });
on<VerifyOTPEvent>((event, emit) async { on<VerifyOTPEvent>((event, emit) async {
emit(AuthLoading()); emit(AuthLoading());
try { try {
// Use the ApiConfig class
final response = await _dio.post( final response = await _dio.post(
'https://fartak.liara.run/login/getcode', ApiConfig.verifyOtp, // <-- تغییر در این خط
data: { data: {
'Phone': event.phoneNumber, 'Phone': event.phoneNumber,
'Code': event.countryCode, 'Code': event.countryCode,
@ -48,7 +83,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
if (response.statusCode == 200 && response.data['data']['accessToken'] != null) { if (response.statusCode == 200 && response.data['data']['accessToken'] != null) {
final accessToken = response.data['data']['accessToken']; final accessToken = response.data['data']['accessToken'];
final refreshToken = response.data['data']['refreshToken']; final refreshToken = response.data['data']['refreshToken'];
@ -57,24 +91,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
refreshToken: refreshToken, refreshToken: refreshToken,
); );
emit(AuthVerified()); add(CheckShopStatus());
} else { } else {
emit(AuthFailure(response.data['message'] ?? 'کد تایید صحیح نمی‌باشد.')); emit(AuthFailure(response.data['message'] ?? 'The verification code is incorrect.'));
} }
} on DioException catch (e) { } on DioException catch (e) {
emit(AuthFailure(e.response?.data['message'] ?? 'خطای شبکه')); emit(AuthFailure(e.response?.data['message'] ?? 'Network error.'));
}
});
on<SaveUserInfoEvent>((event, emit) async {
emit(AuthLoading());
await Future.delayed(const Duration(milliseconds: 500));
if (event.name.trim().isEmpty) {
emit(AuthFailure('لطفاً نام خود را وارد کنید.'));
} else {
emit(UserInfoSaved());
} }
}); });
} }

View File

@ -2,6 +2,10 @@ part of 'auth_bloc.dart';
abstract class AuthEvent {} abstract class AuthEvent {}
class CheckAuthStatus extends AuthEvent {}
class CheckShopStatus extends AuthEvent {}
class SendOTPEvent extends AuthEvent { class SendOTPEvent extends AuthEvent {
final String phoneNumber; final String phoneNumber;
final String countryCode; final String countryCode;

View File

@ -1,4 +1,3 @@
part of 'auth_bloc.dart'; part of 'auth_bloc.dart';
abstract class AuthState {} abstract class AuthState {}
@ -7,6 +6,18 @@ class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {} class AuthLoading extends AuthState {}
// ADDED: State to indicate the result of the token check
class AuthChecked extends AuthState {
final bool hasToken;
AuthChecked(this.hasToken);
}
// ADDED: State for when the user has a shop
class ShopExists extends AuthState {}
// ADDED: State for when the user is logged in but has no shop
class NoShop extends AuthState {}
class AuthCodeSentSuccess extends AuthState {} class AuthCodeSentSuccess extends AuthState {}
class AuthVerified extends AuthState {} class AuthVerified extends AuthState {}

View File

@ -1,9 +1,17 @@
import 'package:business_panel/core/config/api_config.dart';
import 'package:business_panel/core/services/token_storage_service.dart';
import 'package:dio/dio.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'discount_event.dart'; import 'discount_event.dart';
import 'discount_state.dart'; import 'discount_state.dart';
class DiscountBloc extends Bloc<DiscountEvent, DiscountState> { class DiscountBloc extends Bloc<DiscountEvent, DiscountState> {
DiscountBloc() : super(const DiscountState()) { DiscountBloc() : super(const DiscountState()) {
final Dio _dio = Dio();
final TokenStorageService _tokenStorage = TokenStorageService();
on<ProductImageAdded>((event, emit) { on<ProductImageAdded>((event, emit) {
List<String> updatedImages = List.from(state.productImages); List<String> updatedImages = List.from(state.productImages);
if (updatedImages.length > event.index) { if (updatedImages.length > event.index) {
@ -19,7 +27,7 @@ class DiscountBloc extends Bloc<DiscountEvent, DiscountState> {
}); });
on<DiscountTypeChanged>((event, emit) { on<DiscountTypeChanged>((event, emit) {
emit(state.copyWith(discountType: event.type)); emit(state.copyWith(discountTypeId: event.typeId));
}); });
on<DescriptionChanged>((event, emit) { on<DescriptionChanged>((event, emit) {
@ -45,5 +53,59 @@ class DiscountBloc extends Bloc<DiscountEvent, DiscountState> {
on<NotificationRadiusChanged>((event, emit) { on<NotificationRadiusChanged>((event, emit) {
emit(state.copyWith(notificationRadius: event.radius)); emit(state.copyWith(notificationRadius: event.radius));
}); });
on<SubmitDiscount>((event, emit) async {
emit(state.copyWith(isSubmitting: true, errorMessage: null));
try {
final token = await _tokenStorage.getAccessToken();
if (token == null) {
emit(state.copyWith(isSubmitting: false, errorMessage: "خطای احراز هویت."));
return;
}
List<MultipartFile> imageFiles = [];
for (var imagePath in state.productImages) {
imageFiles.add(await MultipartFile.fromFile(
imagePath,
filename: imagePath.split('/').last,
));
}
final data = {
'Name' : state.productName,
'Images': imageFiles,
'Type': state.discountTypeId,
'Description': state.description,
'Price': double.tryParse(state.price),
'NPrice': double.tryParse(state.discountedPrice),
'Start': state.startDate != null ? DateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(state.startDate!) : null,
'End': state.endDate != null ? DateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(state.endDate!) : null,
'StartTime': state.startTime,
'EndTime': state.endTime,
'Radius': state.notificationRadius,
};
final formData = FormData.fromMap(data);
await _dio.post(
ApiConfig.addDiscount,
data: formData,
options: Options(
headers: {'Authorization': 'Bearer $token'},
),
);
emit(state.copyWith(isSubmitting: false, isSuccess: true));
} on DioException catch (e) {
emit(state.copyWith(
isSubmitting: false,
errorMessage: e.response?.data['message'] ?? 'خطای سرور.',
));
} catch (e) {
emit(state.copyWith(isSubmitting: false, errorMessage: 'خطای ناشناخته: ${e.toString()}'));
}
});
} }
} }

View File

@ -13,9 +13,9 @@ class ProductNameChanged extends DiscountEvent {
} }
class DiscountTypeChanged extends DiscountEvent { class DiscountTypeChanged extends DiscountEvent {
final String type; final String typeId;
DiscountTypeChanged(this.type); DiscountTypeChanged(this.typeId);
} }
class DescriptionChanged extends DiscountEvent { class DescriptionChanged extends DiscountEvent {
final String description; final String description;

View File

@ -3,7 +3,7 @@ import 'package:equatable/equatable.dart';
class DiscountState extends Equatable { class DiscountState extends Equatable {
final List<String> productImages; final List<String> productImages;
final String productName; final String productName;
final String? discountType; final String? discountTypeId;
final String description; final String description;
final DateTime? startDate; final DateTime? startDate;
final DateTime? endDate; final DateTime? endDate;
@ -19,7 +19,7 @@ class DiscountState extends Equatable {
const DiscountState({ const DiscountState({
this.productImages = const [], this.productImages = const [],
this.productName = '', this.productName = '',
this.discountType, this.discountTypeId,
this.description = '', this.description = '',
this.startDate, this.startDate,
this.endDate, this.endDate,
@ -36,7 +36,7 @@ class DiscountState extends Equatable {
DiscountState copyWith({ DiscountState copyWith({
List<String>? productImages, List<String>? productImages,
String? productName, String? productName,
String? discountType, String? discountTypeId,
String? description, String? description,
DateTime? startDate, DateTime? startDate,
DateTime? endDate, DateTime? endDate,
@ -52,7 +52,7 @@ class DiscountState extends Equatable {
return DiscountState( return DiscountState(
productImages: productImages ?? this.productImages, productImages: productImages ?? this.productImages,
productName: productName ?? this.productName, productName: productName ?? this.productName,
discountType: discountType ?? this.discountType, discountTypeId: discountTypeId ?? this.discountTypeId,
description: description ?? this.description, description: description ?? this.description,
startDate: startDate ?? this.startDate, startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate, endDate: endDate ?? this.endDate,
@ -71,7 +71,7 @@ class DiscountState extends Equatable {
List<Object?> get props => [ List<Object?> get props => [
productImages, productImages,
productName, productName,
discountType, discountTypeId,
description, description,
startDate, startDate,
endDate, endDate,

View File

@ -0,0 +1,55 @@
import 'package:bloc/bloc.dart';
import 'package:business_panel/core/config/api_config.dart';
import 'package:business_panel/core/services/token_storage_service.dart';
import 'package:business_panel/domain/entities/discount_entity.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
part 'home_event.dart';
part 'home_state.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final Dio _dio = Dio();
final TokenStorageService _tokenStorage = TokenStorageService();
HomeBloc() : super(HomeInitial()) {
on<FetchDiscounts>((event, emit) async {
emit(HomeLoading());
try {
final token = await _tokenStorage.getAccessToken();
if (token == null || token.isEmpty) {
emit(HomeError("خطای احراز هویت. لطفا دوباره وارد شوید."));
return;
}
final response = await _dio.get(
ApiConfig.getDiscounts,
options: Options(
headers: {'Authorization': 'Bearer $token'},
),
);
if (response.statusCode == 200 && response.data['data'] != null) {
final List<dynamic> data = response.data['data'];
final discounts = data.map((json) => DiscountEntity.fromJson(json)).toList();
emit(HomeLoaded(discounts));
} else {
emit(HomeError(response.data['message'] ?? 'خطا در دریافت اطلاعات'));
}
} on DioException catch (e) {
// Log Dio error
if (kDebugMode) {
print('DioException in HomeBloc: ${e.response?.data}');
}
emit(HomeError(e.response?.data['message'] ?? 'خطای شبکه'));
} catch (e, stackTrace) {
// Log any other error
if (kDebugMode) {
print('Error in HomeBloc: $e');
print(stackTrace);
}
emit(HomeError('خطای پیش‌بینی نشده رخ داد: ${e.toString()}'));
}
});
}
}

View File

@ -0,0 +1,5 @@
part of 'home_bloc.dart';
abstract class HomeEvent {}
class FetchDiscounts extends HomeEvent {}

View File

@ -0,0 +1,19 @@
part of 'home_bloc.dart';
abstract class HomeState {}
class HomeInitial extends HomeState {}
class HomeLoading extends HomeState {}
class HomeLoaded extends HomeState {
final List<DiscountEntity> discounts;
HomeLoaded(this.discounts);
}
class HomeError extends HomeState {
final String message;
HomeError(this.message);
}

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:business_panel/core/config/app_colors.dart'; import 'package:business_panel/core/config/app_colors.dart';
import 'package:business_panel/domain/entities/discount_type_entity.dart';
import 'package:business_panel/gen/assets.gen.dart'; import 'package:business_panel/gen/assets.gen.dart';
import 'package:business_panel/presentation/discount/bloc/discount_bloc.dart'; import 'package:business_panel/presentation/discount/bloc/discount_bloc.dart';
import 'package:business_panel/presentation/discount/bloc/discount_event.dart'; import 'package:business_panel/presentation/discount/bloc/discount_event.dart';
@ -86,11 +87,37 @@ class _AddDiscountViewState extends State<_AddDiscountView> {
); );
} }
final List<DiscountTypeEntity> discountTypes = [
DiscountTypeEntity(id: "dda9aef3-367e-48b3-ba35-8e7744d99659", name: "ساعت خوش"),
DiscountTypeEntity(id: "57577fac-7d06-4b2e-a577-7d2ce98fee58", name: "رفیق بازی"),
DiscountTypeEntity(id: "06156635-048b-4ed9-b5d5-2f89824435e1", name: "محصول جانبی رایگان"),
DiscountTypeEntity(id: "16e7d1e9-29c4-4320-9869-3c986cc20734", name: "کالای مکمل"),
DiscountTypeEntity(id: "fb600fbb-bab4-4e63-a25b-d8ffdacb7c09", name: "پلکانی"),
DiscountTypeEntity(id: "488ef29e-415d-4362-b984-509faabac058", name: "دعوت نامه طلایی"),
DiscountTypeEntity(id: "e03e5823-27d8-4f45-bd6c-f7c11822ec7a", name: "بازگشت وجه"),
DiscountTypeEntity(id: "bb0eea57-b630-4373-baff-72df72921e67", name: "سایر"),
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: _buildCustomAppBar(context), appBar: _buildCustomAppBar(context),
body: SingleChildScrollView( body: BlocListener<DiscountBloc, DiscountState>(
listener: (context, state) {
if (state.isSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("تخفیف با موفقیت ثبت شد!"), backgroundColor: Colors.green),
);
// میتوانید به صفحه دیگری ناوبری کنید
// Navigator.of(context).pop();
}
if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage!), backgroundColor: Colors.red),
);
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -168,18 +195,24 @@ class _AddDiscountViewState extends State<_AddDiscountView> {
const SizedBox(height: 30), const SizedBox(height: 30),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: BlocBuilder<DiscountBloc, DiscountState>(
onPressed: () { builder: (context, state) {
// TODO: Implement submit logic return ElevatedButton(
onPressed: state.isSubmitting ? null : () {
context.read<DiscountBloc>().add(SubmitDiscount());
},
child: state.isSubmitting
? const CircularProgressIndicator(color: Colors.white)
: const Text("ثبت تخفیف"),
);
}, },
child: const Text("ثبت تخفیف"),
), ),
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
], ],
), ),
), ),
); ));
} }
Widget _buildSectionTitle({ Widget _buildSectionTitle({
@ -268,25 +301,15 @@ class _AddDiscountViewState extends State<_AddDiscountView> {
} }
Widget _buildDiscountTypeDropdown() { Widget _buildDiscountTypeDropdown() {
final List<String> discountTypes = [
"ساعت خوش",
"رفیق بازی",
"محصول جانبی رایگان",
"کالای مکمل",
"پلکانی",
"دعوتنامه طلایی",
"بازگشت وجه",
"سایر",
];
return DropdownButtonFormField<String>( return DropdownButtonFormField<String>(
value: context.watch<DiscountBloc>().state.discountTypeId,
icon: SvgPicture.asset( icon: SvgPicture.asset(
Assets.icons.arrowDown, Assets.icons.arrowDown,
width: 24, width: 24,
color: Colors.black, color: Colors.black,
), ),
menuMaxHeight: 400, menuMaxHeight: 400,
hint: Text("ساعت خوش"), hint: const Text("نوع تخفیف را انتخاب کنید"),
decoration: _inputDecoration("نوع تخفیف", isRequired: true).copyWith( decoration: _inputDecoration("نوع تخفیف", isRequired: true).copyWith(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
vertical: 14, vertical: 14,
@ -294,10 +317,9 @@ class _AddDiscountViewState extends State<_AddDiscountView> {
), ),
), ),
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
items: items: discountTypes.map((type) {
discountTypes return DropdownMenuItem(value: type.id, child: Text(type.name));
.map((type) => DropdownMenuItem(value: type, child: Text(type))) }).toList(),
.toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
context.read<DiscountBloc>().add(DiscountTypeChanged(value)); context.read<DiscountBloc>().add(DiscountTypeChanged(value));

View File

@ -0,0 +1,380 @@
import 'package:business_panel/core/config/app_colors.dart';
import 'package:business_panel/domain/entities/discount_entity.dart';
import 'package:business_panel/gen/assets.gen.dart';
import 'package:business_panel/presentation/home/bloc/home_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/svg.dart';
import 'package:slide_countdown/slide_countdown.dart';
import 'package:business_panel/presentation/pages/add_discount_page.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
@override
void initState() {
super.initState();
context.read<HomeBloc>().add(FetchDiscounts());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildCustomAppBar(context),
body: BlocBuilder<HomeBloc, HomeState>(
builder: (context, state) {
if (state is HomeLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is HomeError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('خطا: ${state.message}', textAlign: TextAlign.center),
),
);
}
if (state is HomeLoaded) {
if (state.discounts.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: () async {
context.read<HomeBloc>().add(FetchDiscounts());
},
child: ListView.builder(
padding: const EdgeInsets.all(16),
// تعداد آیتمها یکی بیشتر از تعداد تخفیفهاست تا دکمه هم جا شود
itemCount: state.discounts.length + 1,
itemBuilder: (context, index) {
// اگر ایندکس مربوط به آخرین آیتم بود، دکمه را نمایش بده
if (index == state.discounts.length) {
return _buildAddDiscountButton();
}
// در غیر این صورت، کارت تخفیف را نمایش بده
final discount = state.discounts[index];
return _buildDiscountCard(discount);
},
),
);
}
// حالت پیشفرض
return _buildEmptyState();
},
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(Assets.images.emptyHome, height: 200),
const SizedBox(height: 20),
const Text(
"هنوز تخفیفی ثبت نکرده‌اید",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: _buildAddDiscountButton(),
),
],
),
);
}
Widget _buildAddDiscountButton() {
return Padding(
padding: const EdgeInsets.only(top: 16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.confirm,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40),
),
),
onPressed: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const AddDiscountPage()))
.then((_) {
// رفرش لیست بعد از بازگشت از صفحه افزودن
context.read<HomeBloc>().add(FetchDiscounts());
});
},
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add, color: Colors.white),
SizedBox(width: 8),
Text(
"تعریف تخفیف جدید",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.normal,
color: Colors.white,
),
),
],
),
),
),
);
}
Widget _buildDiscountCard(DiscountEntity discount) {
final remaining = discount.endDate != null
? discount.endDate!.difference(DateTime.now())
: const Duration(seconds: -1);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
"تخفیف ${discount.type}",
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
),
InkWell(
onTap: () {
// TODO: Implement edit functionality
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
SvgPicture.asset(Assets.icons.edit, width: 20, color: AppColors.active),
const SizedBox(width: 5),
const Text("ویرایش", style: TextStyle(color: AppColors.active)),
],
),
),
),
],
),
const Divider(height: 1),
const SizedBox(height: 10),
Card(
color: Colors.white,
elevation: 0,
margin: const EdgeInsets.only(bottom: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
side: BorderSide(color: Colors.grey.shade300, width: 1),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (discount.images.isNotEmpty && discount.images.first.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(15),
child: Image.network(
discount.images.first,
width: 100,
height: 100,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(15),
),
child: const Icon(Icons.image_not_supported, color: Colors.grey),
),
),
)
else
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(15),
),
child: const Icon(Icons.store, color: Colors.grey, size: 50),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Row(
children: [
SvgPicture.asset(Assets.icons.shop, width: 18, color: Colors.grey.shade700),
const SizedBox(width: 10),
Expanded(
child: Text(
discount.shopName,
style: const TextStyle(fontSize: 16),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
SvgPicture.asset(Assets.icons.shoppingCart, width: 18, color: Colors.grey.shade700),
const SizedBox(width: 10),
Expanded(
child: Text(
discount.name,
style: TextStyle(fontSize: 15, color: Colors.grey.shade600),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
if (discount.endDate == null)
const Text(
'تاریخ نامعتبر',
style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold),
)
else if (remaining.isNegative)
const Text(
'منقضی شده',
style: TextStyle(color: AppColors.expiryReserve, fontWeight: FontWeight.bold),
)
else
Row(
children: [
SvgPicture.asset(Assets.icons.timerPause, width: 18, color: Colors.grey.shade700),
const SizedBox(width: 10),
Expanded(child: _buildCountdownTimer(remaining)),
],
),
],
),
),
],
),
),
),
],
);
}
Widget _buildCountdownTimer(Duration remaining) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Localizations.override(
context: context,
locale: const Locale('en'),
child: SlideCountdown(
duration: remaining,
slideDirection: SlideDirection.up,
separator: ':',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppColors.countdown,
),
separatorStyle: const TextStyle(
fontSize: 15,
color: AppColors.countdown,
),
decoration: const BoxDecoration(color: Colors.transparent),
shouldShowDays: (d) => d.inDays > 0,
shouldShowHours: (d) => true,
shouldShowMinutes: (d) => true,
),
),
const SizedBox(height: 4),
_buildTimerLabels(remaining),
],
);
}
Widget _buildTimerLabels(Duration duration) {
const labelStyle = TextStyle(fontSize: 10, color: AppColors.selectedImg);
List<Widget> labels = [];
if (duration.inDays > 0) {
labels.add(const SizedBox(width: 30, child: Text("روز", style: labelStyle)));
}
if (duration.inHours > 0 || duration.inDays > 0) {
labels.add(const SizedBox(width: 35, child: Text("ساعت", style: labelStyle)));
}
labels.add(const SizedBox(width: 30, child: Text("دقیقه", style: labelStyle)));
labels.add(const SizedBox(width: 30, child: Text("ثانیه", style: labelStyle)));
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: labels.reversed.toList(),
);
}
PreferredSizeWidget _buildCustomAppBar(BuildContext context) {
return PreferredSize(
preferredSize: const Size.fromHeight(70.0),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(15),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SvgPicture.asset(Assets.icons.logoWithName),
Row(
children: [
IconButton(
onPressed: () {},
icon: SvgPicture.asset(
Assets.icons.discountShape,
color: Colors.black,
),
),
IconButton(
onPressed: () {},
icon: SvgPicture.asset(Assets.icons.scanBarcode),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -107,17 +107,17 @@ class _LoginPageState extends State<LoginPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( // Row(
children: [ // children: [
Checkbox( // Checkbox(
value: _keepSignedIn, // value: _keepSignedIn,
onChanged: (value) => // onChanged: (value) =>
setState(() => _keepSignedIn = value ?? false), // setState(() => _keepSignedIn = value ?? false),
activeColor: AppColors.primary, // activeColor: AppColors.primary,
), // ),
Text("مرا به خاطر بسپار", style: textTheme.bodyMedium), // Text("مرا به خاطر بسپار", style: textTheme.bodyMedium),
], // ],
), // ),
const SizedBox(height: 24), const SizedBox(height: 24),
BlocConsumer<AuthBloc, AuthState>( BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) { listener: (context, state) {

View File

@ -1,6 +1,8 @@
import 'package:business_panel/core/config/app_colors.dart'; import 'package:business_panel/core/config/app_colors.dart';
import 'package:business_panel/gen/assets.gen.dart'; import 'package:business_panel/gen/assets.gen.dart';
import 'package:business_panel/presentation/auth/bloc/auth_bloc.dart'; import 'package:business_panel/presentation/auth/bloc/auth_bloc.dart';
import 'package:business_panel/presentation/home/bloc/home_bloc.dart';
import 'package:business_panel/presentation/pages/home_page.dart';
import 'package:business_panel/presentation/pages/store_info.dart'; import 'package:business_panel/presentation/pages/store_info.dart';
import 'package:business_panel/presentation/store_info/bloc/store_info_bloc.dart'; import 'package:business_panel/presentation/store_info/bloc/store_info_bloc.dart';
import 'package:business_panel/presentation/utils/otp_timer_helper.dart'; import 'package:business_panel/presentation/utils/otp_timer_helper.dart';
@ -139,6 +141,8 @@ class _OtpPageState extends State<OtpPage> {
) )
else else
const SizedBox(height: 32), const SizedBox(height: 32),
// *** CHANGE IS HERE ***
BlocConsumer<AuthBloc, AuthState>( BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) { listener: (context, state) {
if (state is AuthFailure) { if (state is AuthFailure) {
@ -147,7 +151,20 @@ class _OtpPageState extends State<OtpPage> {
_errorMessage = state.message; _errorMessage = state.message;
}); });
} }
if (state is AuthVerified) {
if (state is ShopExists) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (_) => BlocProvider(
create: (context) => HomeBloc(),
child: const HomePage(),
),
),
(route) => false,
);
}
// If no shop, navigate to StoreInfoPage to create one
if (state is NoShop) {
Navigator.of(context).pushAndRemoveUntil( Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => BlocProvider( builder: (_) => BlocProvider(

View File

@ -0,0 +1,70 @@
import 'package:business_panel/presentation/auth/bloc/auth_bloc.dart';
import 'package:business_panel/presentation/home/bloc/home_bloc.dart';
import 'package:business_panel/presentation/pages/home_page.dart';
import 'package:business_panel/presentation/pages/onboarding_page.dart';
import 'package:business_panel/presentation/pages/store_info.dart';
import 'package:business_panel/presentation/store_info/bloc/store_info_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
State<SplashPage> createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
@override
void initState() {
super.initState();
context.read<AuthBloc>().add(CheckAuthStatus());
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthChecked) {
if (state.hasToken) {
context.read<AuthBloc>().add(CheckShopStatus());
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const OnboardingPage()),
);
}
} else if (state is ShopExists) {
// کاربر هم توکن دارد و هم فروشگاه، به HomePage میرود
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => BlocProvider(
create: (context) => HomeBloc(),
child: const HomePage(),
),
),
);
} else if (state is NoShop) {
// کاربر توکن دارد ولی فروشگاه نساخته، به صفحه ساخت فروشگاه میرود
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => BlocProvider(
create: (context) => StoreInfoBloc(),
child: const StoreInfoPage(),
),
),
);
} else if (state is AuthFailure) {
// اگر در هر مرحله خطایی رخ داد، کاربر را به صفحه لاگین بفرست
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const OnboardingPage()),
);
}
},
child: const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:business_panel/core/config/api_config.dart';
import 'package:business_panel/core/services/token_storage_service.dart'; import 'package:business_panel/core/services/token_storage_service.dart';
import 'package:business_panel/presentation/store_info/bloc/store_info_state.dart'; import 'package:business_panel/presentation/store_info/bloc/store_info_state.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -111,6 +112,7 @@ class StoreInfoBloc extends Bloc<StoreInfoEvent, StoreInfoState> {
'Address': state.address, 'Address': state.address,
'Property': jsonEncode(state.features), 'Property': jsonEncode(state.features),
'ShopNumber': state.plaque, 'ShopNumber': state.plaque,
'Phone': state.contactPhone,
'PostalCode': state.postalCode, 'PostalCode': state.postalCode,
'BusinessLicense': state.licenseNumber, 'BusinessLicense': state.licenseNumber,
'Coordinates': jsonEncode({ 'Coordinates': jsonEncode({
@ -130,7 +132,7 @@ class StoreInfoBloc extends Bloc<StoreInfoEvent, StoreInfoState> {
final formData = FormData.fromMap(data); final formData = FormData.fromMap(data);
await _dio.post( await _dio.post(
'https://fartak.liara.run/shop/add', ApiConfig.addStore,
data: formData, data: formData,
options: Options( options: Options(
headers: {'Authorization': 'Bearer $token'}, headers: {'Authorization': 'Bearer $token'},
@ -140,6 +142,7 @@ class StoreInfoBloc extends Bloc<StoreInfoEvent, StoreInfoState> {
emit(state.copyWith(isSubmitting: false, isSuccess: true)); emit(state.copyWith(isSubmitting: false, isSuccess: true));
} on DioException catch (e) { } on DioException catch (e) {
print(e.response?.data['message']);
emit(state.copyWith( emit(state.copyWith(
isSubmitting: false, isSubmitting: false,
errorMessage: e.response?.data['message'] ?? 'خطایی در ارتباط با سرور رخ داد.')); errorMessage: e.response?.data['message'] ?? 'خطایی در ارتباط با سرور رخ داد.'));

View File

@ -893,6 +893,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pausable_timer:
dependency: transitive
description:
name: pausable_timer
sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074"
url: "https://pub.dev"
source: hosted
version: "3.1.0+3"
persian_datetime_picker: persian_datetime_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1066,6 +1074,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
slide_countdown:
dependency: "direct main"
description:
name: slide_countdown
sha256: "363914f96389502467d4dc9c0f26e88f93df3d8e37de2d5ff05b16d981fe973d"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:

View File

@ -48,6 +48,7 @@ dependencies:
geolocator: ^14.0.2 geolocator: ^14.0.2
dio: ^5.8.0+1 dio: ^5.8.0+1
flutter_secure_storage: ^9.2.4 flutter_secure_storage: ^9.2.4
slide_countdown: ^2.0.2
dev_dependencies: dev_dependencies: