diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart new file mode 100644 index 0000000..775a665 --- /dev/null +++ b/lib/core/config/api_config.dart @@ -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 '} + static const String checkShopStatus = '$baseUrl/shop/get'; + + // ========== Store Endpoints ========== + /// Endpoint to add a new store. + /// Method: POST + /// Body: FormData + /// Headers: {'Authorization': 'Bearer '} + static const String addStore = '$baseUrl/shop/add'; + + // ========== Discount Endpoints ========== + /// Endpoint to add a new discount. + /// Method: POST + /// Body: FormData + /// Headers: {'Authorization': 'Bearer '} + static const String addDiscount = '$baseUrl/discount/add'; + static const String getDiscounts = '$baseUrl/discount/get'; +} diff --git a/lib/core/config/app_colors.dart b/lib/core/config/app_colors.dart index 5fbbd02..30f784e 100644 --- a/lib/core/config/app_colors.dart +++ b/lib/core/config/app_colors.dart @@ -17,4 +17,5 @@ class AppColors { static const Color countdownBorderRserve = Color.fromARGB(255, 186, 222, 251); static const Color expiryReserve = Color.fromARGB(255, 183, 28, 28); static const Color uploadElevated = Color.fromARGB(255, 233, 245, 254); + static const Color secTitle = Color.fromARGB(255, 95, 95, 95); } \ No newline at end of file diff --git a/lib/domain/entities/discount_entity.dart b/lib/domain/entities/discount_entity.dart new file mode 100644 index 0000000..621331c --- /dev/null +++ b/lib/domain/entities/discount_entity.dart @@ -0,0 +1,47 @@ +class DiscountEntity { + final String id; + final String name; + final String shopName; + final List 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 json) { + // A helper function to safely extract lists + List _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, + ); + } +} \ No newline at end of file diff --git a/lib/domain/entities/discount_type_entity.dart b/lib/domain/entities/discount_type_entity.dart new file mode 100644 index 0000000..8312e25 --- /dev/null +++ b/lib/domain/entities/discount_type_entity.dart @@ -0,0 +1,6 @@ +class DiscountTypeEntity { + final String id; + final String name; + + DiscountTypeEntity({required this.id, required this.name}); +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index e260347..ecc251b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:business_panel/core/config/app_colors.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/splash_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -99,8 +100,9 @@ class MyApp extends StatelessWidget { ), ), ), - home: const OnboardingPage(), + home: const SplashPage(), ), ); } } + \ No newline at end of file diff --git a/lib/presentation/auth/bloc/auth_bloc.dart b/lib/presentation/auth/bloc/auth_bloc.dart index 2e68cb0..c9acc22 100644 --- a/lib/presentation/auth/bloc/auth_bloc.dart +++ b/lib/presentation/auth/bloc/auth_bloc.dart @@ -1,45 +1,80 @@ 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:dio/dio.dart'; -import 'package:business_panel/core/utils/logging_interceptor.dart'; +import 'package:business_panel/core/utils/logging_interceptor.dart'; part 'auth_event.dart'; part 'auth_state.dart'; class AuthBloc extends Bloc { - late final Dio _dio; final TokenStorageService _tokenStorage = TokenStorageService(); + AuthBloc() : super(AuthInitial()) { _dio = Dio(); _dio.interceptors.add(LoggingInterceptor()); - on((event, emit) async { + on((event, emit) async { emit(AuthLoading()); + final token = await _tokenStorage.getAccessToken(); + emit(AuthChecked(token != null && token.isNotEmpty)); + }); + + on((event, emit) async { emit(AuthLoading()); 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((event, emit) async { + emit(AuthLoading()); + try { + // Use the ApiConfig class final response = await _dio.post( - 'https://fartak.liara.run/login/sendcode', - data: { - 'Phone': event.phoneNumber, - 'Code': event.countryCode, - }, + ApiConfig.sendOtp, // <-- تغییر در این خط + data: {'Phone': event.phoneNumber, 'Code': event.countryCode}, ); if (response.statusCode == 200) { emit(AuthCodeSentSuccess()); } else { - emit(AuthFailure(response.data['message'] ?? 'خطایی رخ داد')); + emit(AuthFailure(response.data['message'] ?? 'An error occurred.')); } } on DioException catch (e) { - emit(AuthFailure(e.response?.data['message'] ?? 'خطای شبکه')); + emit(AuthFailure(e.response?.data['message'] ?? 'Network error.')); } }); on((event, emit) async { emit(AuthLoading()); try { + // Use the ApiConfig class final response = await _dio.post( - 'https://fartak.liara.run/login/getcode', + ApiConfig.verifyOtp, // <-- تغییر در این خط data: { 'Phone': event.phoneNumber, 'Code': event.countryCode, @@ -48,7 +83,6 @@ class AuthBloc extends Bloc { ); if (response.statusCode == 200 && response.data['data']['accessToken'] != null) { - final accessToken = response.data['data']['accessToken']; final refreshToken = response.data['data']['refreshToken']; @@ -57,24 +91,13 @@ class AuthBloc extends Bloc { refreshToken: refreshToken, ); - emit(AuthVerified()); + add(CheckShopStatus()); + } else { - emit(AuthFailure(response.data['message'] ?? 'کد تایید صحیح نمی‌باشد.')); + emit(AuthFailure(response.data['message'] ?? 'The verification code is incorrect.')); } } on DioException catch (e) { - emit(AuthFailure(e.response?.data['message'] ?? 'خطای شبکه')); - } - }); - - - on((event, emit) async { - emit(AuthLoading()); - await Future.delayed(const Duration(milliseconds: 500)); - - if (event.name.trim().isEmpty) { - emit(AuthFailure('لطفاً نام خود را وارد کنید.')); - } else { - emit(UserInfoSaved()); + emit(AuthFailure(e.response?.data['message'] ?? 'Network error.')); } }); } diff --git a/lib/presentation/auth/bloc/auth_event.dart b/lib/presentation/auth/bloc/auth_event.dart index a21dcc0..0ea0d6c 100644 --- a/lib/presentation/auth/bloc/auth_event.dart +++ b/lib/presentation/auth/bloc/auth_event.dart @@ -2,6 +2,10 @@ part of 'auth_bloc.dart'; abstract class AuthEvent {} +class CheckAuthStatus extends AuthEvent {} + +class CheckShopStatus extends AuthEvent {} + class SendOTPEvent extends AuthEvent { final String phoneNumber; final String countryCode; diff --git a/lib/presentation/auth/bloc/auth_state.dart b/lib/presentation/auth/bloc/auth_state.dart index ff99825..5e7e52a 100644 --- a/lib/presentation/auth/bloc/auth_state.dart +++ b/lib/presentation/auth/bloc/auth_state.dart @@ -1,4 +1,3 @@ - part of 'auth_bloc.dart'; abstract class AuthState {} @@ -7,6 +6,18 @@ class AuthInitial 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 AuthVerified extends AuthState {} diff --git a/lib/presentation/discount/bloc/discount_bloc.dart b/lib/presentation/discount/bloc/discount_bloc.dart index b040851..ba2c9cf 100644 --- a/lib/presentation/discount/bloc/discount_bloc.dart +++ b/lib/presentation/discount/bloc/discount_bloc.dart @@ -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:intl/intl.dart'; import 'discount_event.dart'; import 'discount_state.dart'; class DiscountBloc extends Bloc { DiscountBloc() : super(const DiscountState()) { + + final Dio _dio = Dio(); + final TokenStorageService _tokenStorage = TokenStorageService(); + on((event, emit) { List updatedImages = List.from(state.productImages); if (updatedImages.length > event.index) { @@ -19,13 +27,13 @@ class DiscountBloc extends Bloc { }); on((event, emit) { - emit(state.copyWith(discountType: event.type)); + emit(state.copyWith(discountTypeId: event.typeId)); }); on((event, emit) { emit(state.copyWith(description: event.description)); }); - + on((event, emit) { emit(state.copyWith(startDate: event.startDate, endDate: event.endDate)); }); @@ -45,5 +53,59 @@ class DiscountBloc extends Bloc { on((event, emit) { emit(state.copyWith(notificationRadius: event.radius)); }); + + on((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 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()}')); + } + }); } -} \ No newline at end of file +} diff --git a/lib/presentation/discount/bloc/discount_event.dart b/lib/presentation/discount/bloc/discount_event.dart index 1ff9850..3b9a29a 100644 --- a/lib/presentation/discount/bloc/discount_event.dart +++ b/lib/presentation/discount/bloc/discount_event.dart @@ -13,9 +13,9 @@ class ProductNameChanged extends DiscountEvent { } class DiscountTypeChanged extends DiscountEvent { - final String type; - DiscountTypeChanged(this.type); -} + final String typeId; + DiscountTypeChanged(this.typeId); + } class DescriptionChanged extends DiscountEvent { final String description; diff --git a/lib/presentation/discount/bloc/discount_state.dart b/lib/presentation/discount/bloc/discount_state.dart index 9c48637..4a00a36 100644 --- a/lib/presentation/discount/bloc/discount_state.dart +++ b/lib/presentation/discount/bloc/discount_state.dart @@ -3,7 +3,7 @@ import 'package:equatable/equatable.dart'; class DiscountState extends Equatable { final List productImages; final String productName; - final String? discountType; + final String? discountTypeId; final String description; final DateTime? startDate; final DateTime? endDate; @@ -19,7 +19,7 @@ class DiscountState extends Equatable { const DiscountState({ this.productImages = const [], this.productName = '', - this.discountType, + this.discountTypeId, this.description = '', this.startDate, this.endDate, @@ -36,7 +36,7 @@ class DiscountState extends Equatable { DiscountState copyWith({ List? productImages, String? productName, - String? discountType, + String? discountTypeId, String? description, DateTime? startDate, DateTime? endDate, @@ -52,7 +52,7 @@ class DiscountState extends Equatable { return DiscountState( productImages: productImages ?? this.productImages, productName: productName ?? this.productName, - discountType: discountType ?? this.discountType, + discountTypeId: discountTypeId ?? this.discountTypeId, description: description ?? this.description, startDate: startDate ?? this.startDate, endDate: endDate ?? this.endDate, @@ -71,7 +71,7 @@ class DiscountState extends Equatable { List get props => [ productImages, productName, - discountType, + discountTypeId, description, startDate, endDate, diff --git a/lib/presentation/home/bloc/home_bloc.dart b/lib/presentation/home/bloc/home_bloc.dart new file mode 100644 index 0000000..b0107f7 --- /dev/null +++ b/lib/presentation/home/bloc/home_bloc.dart @@ -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 { + final Dio _dio = Dio(); + final TokenStorageService _tokenStorage = TokenStorageService(); + + HomeBloc() : super(HomeInitial()) { + on((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 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()}')); + } + }); + } +} \ No newline at end of file diff --git a/lib/presentation/home/bloc/home_event.dart b/lib/presentation/home/bloc/home_event.dart new file mode 100644 index 0000000..f788ef1 --- /dev/null +++ b/lib/presentation/home/bloc/home_event.dart @@ -0,0 +1,5 @@ +part of 'home_bloc.dart'; + +abstract class HomeEvent {} + +class FetchDiscounts extends HomeEvent {} \ No newline at end of file diff --git a/lib/presentation/home/bloc/home_state.dart b/lib/presentation/home/bloc/home_state.dart new file mode 100644 index 0000000..f3e9d57 --- /dev/null +++ b/lib/presentation/home/bloc/home_state.dart @@ -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 discounts; + + HomeLoaded(this.discounts); +} + +class HomeError extends HomeState { + final String message; + + HomeError(this.message); +} \ No newline at end of file diff --git a/lib/presentation/pages/add_discount_page.dart b/lib/presentation/pages/add_discount_page.dart index 0191eee..8633f23 100644 --- a/lib/presentation/pages/add_discount_page.dart +++ b/lib/presentation/pages/add_discount_page.dart @@ -1,5 +1,6 @@ import 'dart:io'; 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/presentation/discount/bloc/discount_bloc.dart'; import 'package:business_panel/presentation/discount/bloc/discount_event.dart'; @@ -86,15 +87,41 @@ class _AddDiscountViewState extends State<_AddDiscountView> { ); } + final List 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 Widget build(BuildContext context) { return Scaffold( appBar: _buildCustomAppBar(context), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + body: BlocListener( + 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), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ const Text( "تعریف تخفیف جدید", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), @@ -166,20 +193,26 @@ class _AddDiscountViewState extends State<_AddDiscountView> { const SizedBox(height: 30), _buildNotificationRadiusSlider(), const SizedBox(height: 30), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - // TODO: Implement submit logic - }, - child: const Text("ثبت تخفیف"), + SizedBox( + width: double.infinity, + child: BlocBuilder( + builder: (context, state) { + return ElevatedButton( + onPressed: state.isSubmitting ? null : () { + context.read().add(SubmitDiscount()); + }, + child: state.isSubmitting + ? const CircularProgressIndicator(color: Colors.white) + : const Text("ثبت تخفیف"), + ); + }, + ), ), - ), - const SizedBox(height: 30), - ], + const SizedBox(height: 30), + ], ), ), - ); + )); } Widget _buildSectionTitle({ @@ -267,26 +300,16 @@ class _AddDiscountViewState extends State<_AddDiscountView> { ); } - Widget _buildDiscountTypeDropdown() { - final List discountTypes = [ - "ساعت خوش", - "رفیق بازی", - "محصول جانبی رایگان", - "کالای مکمل", - "پلکانی", - "دعوتنامه طلایی", - "بازگشت وجه", - "سایر", - ]; - + Widget _buildDiscountTypeDropdown() { return DropdownButtonFormField( + value: context.watch().state.discountTypeId, icon: SvgPicture.asset( Assets.icons.arrowDown, width: 24, color: Colors.black, ), menuMaxHeight: 400, - hint: Text("ساعت خوش"), + hint: const Text("نوع تخفیف را انتخاب کنید"), decoration: _inputDecoration("نوع تخفیف", isRequired: true).copyWith( contentPadding: const EdgeInsets.symmetric( vertical: 14, @@ -294,10 +317,9 @@ class _AddDiscountViewState extends State<_AddDiscountView> { ), ), borderRadius: BorderRadius.circular(12.0), - items: - discountTypes - .map((type) => DropdownMenuItem(value: type, child: Text(type))) - .toList(), + items: discountTypes.map((type) { + return DropdownMenuItem(value: type.id, child: Text(type.name)); + }).toList(), onChanged: (value) { if (value != null) { context.read().add(DiscountTypeChanged(value)); diff --git a/lib/presentation/pages/home_page.dart b/lib/presentation/pages/home_page.dart new file mode 100644 index 0000000..c537745 --- /dev/null +++ b/lib/presentation/pages/home_page.dart @@ -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 createState() => _HomePageState(); +} + +class _HomePageState extends State { + @override + void initState() { + super.initState(); + context.read().add(FetchDiscounts()); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildCustomAppBar(context), + body: BlocBuilder( + 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().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().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 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), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/pages/login_page.dart b/lib/presentation/pages/login_page.dart index bf9e26e..37f2e3a 100644 --- a/lib/presentation/pages/login_page.dart +++ b/lib/presentation/pages/login_page.dart @@ -107,17 +107,17 @@ class _LoginPageState extends State { ), ), const SizedBox(height: 16), - Row( - children: [ - Checkbox( - value: _keepSignedIn, - onChanged: (value) => - setState(() => _keepSignedIn = value ?? false), - activeColor: AppColors.primary, - ), - Text("مرا به خاطر بسپار", style: textTheme.bodyMedium), - ], - ), + // Row( + // children: [ + // Checkbox( + // value: _keepSignedIn, + // onChanged: (value) => + // setState(() => _keepSignedIn = value ?? false), + // activeColor: AppColors.primary, + // ), + // Text("مرا به خاطر بسپار", style: textTheme.bodyMedium), + // ], + // ), const SizedBox(height: 24), BlocConsumer( listener: (context, state) { diff --git a/lib/presentation/pages/otp_page.dart b/lib/presentation/pages/otp_page.dart index aa19afa..d167308 100644 --- a/lib/presentation/pages/otp_page.dart +++ b/lib/presentation/pages/otp_page.dart @@ -1,6 +1,8 @@ import 'package:business_panel/core/config/app_colors.dart'; import 'package:business_panel/gen/assets.gen.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/store_info/bloc/store_info_bloc.dart'; import 'package:business_panel/presentation/utils/otp_timer_helper.dart'; @@ -139,6 +141,8 @@ class _OtpPageState extends State { ) else const SizedBox(height: 32), + + // *** CHANGE IS HERE *** BlocConsumer( listener: (context, state) { if (state is AuthFailure) { @@ -147,7 +151,20 @@ class _OtpPageState extends State { _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( MaterialPageRoute( builder: (_) => BlocProvider( diff --git a/lib/presentation/pages/splash_page.dart b/lib/presentation/pages/splash_page.dart new file mode 100644 index 0000000..27c10fc --- /dev/null +++ b/lib/presentation/pages/splash_page.dart @@ -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 createState() => _SplashPageState(); +} + +class _SplashPageState extends State { + @override + void initState() { + super.initState(); + context.read().add(CheckAuthStatus()); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state is AuthChecked) { + if (state.hasToken) { + context.read().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(), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/store_info/bloc/store_info_bloc.dart b/lib/presentation/store_info/bloc/store_info_bloc.dart index 9c499d5..050fe3e 100644 --- a/lib/presentation/store_info/bloc/store_info_bloc.dart +++ b/lib/presentation/store_info/bloc/store_info_bloc.dart @@ -1,6 +1,7 @@ import 'dart:convert'; 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/presentation/store_info/bloc/store_info_state.dart'; import 'package:dio/dio.dart'; @@ -111,6 +112,7 @@ class StoreInfoBloc extends Bloc { 'Address': state.address, 'Property': jsonEncode(state.features), 'ShopNumber': state.plaque, + 'Phone': state.contactPhone, 'PostalCode': state.postalCode, 'BusinessLicense': state.licenseNumber, 'Coordinates': jsonEncode({ @@ -130,7 +132,7 @@ class StoreInfoBloc extends Bloc { final formData = FormData.fromMap(data); await _dio.post( - 'https://fartak.liara.run/shop/add', + ApiConfig.addStore, data: formData, options: Options( headers: {'Authorization': 'Bearer $token'}, @@ -140,6 +142,7 @@ class StoreInfoBloc extends Bloc { emit(state.copyWith(isSubmitting: false, isSuccess: true)); } on DioException catch (e) { + print(e.response?.data['message']); emit(state.copyWith( isSubmitting: false, errorMessage: e.response?.data['message'] ?? 'خطایی در ارتباط با سرور رخ داد.')); diff --git a/pubspec.lock b/pubspec.lock index 2983bed..4d94239 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -893,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -1066,6 +1074,14 @@ packages: description: flutter source: sdk 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4d313ec..ea2895e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: geolocator: ^14.0.2 dio: ^5.8.0+1 flutter_secure_storage: ^9.2.4 + slide_countdown: ^2.0.2 dev_dependencies: