From 9a1550ac24833692f1ff17ead8a831d84e7878c9 Mon Sep 17 00:00:00 2001 From: mohamadmahdi jebeli Date: Mon, 11 Aug 2025 13:26:01 +0330 Subject: [PATCH] added analytics mangment --- lib/core/config/api_config.dart | 43 +- lib/core/config/app_colors.dart | 1 + lib/core/config/http_overrides.dart | 10 + lib/domain/entities/comment_entity.dart | 45 ++ lib/domain/entities/sales_stats_entity.dart | 22 + lib/main.dart | 8 + .../pages/discount_manegment_page.dart | 5 +- .../pages/sales_analysis_page.dart | 410 ++++++++++++++++++ .../bloc/sales_analysis_bloc.dart | 123 ++++++ .../bloc/sales_analysis_event.dart | 22 + .../bloc/sales_analysis_state.dart | 57 +++ .../widgets/analytics_discount_card.dart | 25 +- .../widgets/comments_section.dart | 38 ++ lib/presentation/widgets/custom_app_bar.dart | 188 ++++---- .../widgets/reservation_card.dart | 0 .../widgets/user_comment_card.dart | 133 ++++++ pubspec.lock | 8 + pubspec.yaml | 1 + 18 files changed, 997 insertions(+), 142 deletions(-) create mode 100644 lib/core/config/http_overrides.dart create mode 100644 lib/domain/entities/comment_entity.dart create mode 100644 lib/domain/entities/sales_stats_entity.dart create mode 100644 lib/presentation/pages/sales_analysis_page.dart create mode 100644 lib/presentation/sales_analysis/bloc/sales_analysis_bloc.dart create mode 100644 lib/presentation/sales_analysis/bloc/sales_analysis_event.dart create mode 100644 lib/presentation/sales_analysis/bloc/sales_analysis_state.dart create mode 100644 lib/presentation/widgets/comments_section.dart create mode 100644 lib/presentation/widgets/reservation_card.dart create mode 100644 lib/presentation/widgets/user_comment_card.dart diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart index 07756dc..144327a 100644 --- a/lib/core/config/api_config.dart +++ b/lib/core/config/api_config.dart @@ -4,61 +4,34 @@ class ApiConfig { // Base URL for the API static const String baseUrl = 'https://fartak.liara.run'; + // Base URL for the Comments API + static const String proxyBuyBaseUrl = 'https://proxybuy.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'; static const String getActiveDiscounts = '$baseUrl/discount/get?status=1'; - - /// Endpoint to get a single discount by its ID. - /// Method: GET - /// Headers: {'Authorization': 'Bearer '} static String getDiscountById(String id) => '$baseUrl/discount/get/$id'; - - /// Endpoint to edit an existing discount. - /// Method: POST - /// Body: FormData - /// Headers: {'Authorization': 'Bearer '} static String editDiscount(String id) => '$baseUrl/discount/edit/$id'; static String deleteDiscount(String id) => '$baseUrl/discount/delete/$id'; // ========== Order Endpoints ========== - /// Endpoint to add a new order. - /// Method: POST - /// Body: {'Discount': discountId, 'User': userId} - /// Headers: {'Authorization': 'Bearer '} static const String addOrder = '$baseUrl/order/add'; + static const String getOrderStats = '$baseUrl/order/get'; + static String getOrderStatus(String id) => '$baseUrl/order/status/$id'; // ========== Reservation Endpoints ========== - /// Endpoint to get reservations. - /// Method: GET - /// Headers: {'Authorization': 'Bearer '} static const String getReservations = '$baseUrl/reservation/get'; + + // ========== Comment Endpoints ========== + static String getComments(String discountId) => '$baseUrl/comment/get/$discountId'; } \ No newline at end of file diff --git a/lib/core/config/app_colors.dart b/lib/core/config/app_colors.dart index d59507c..761bc89 100644 --- a/lib/core/config/app_colors.dart +++ b/lib/core/config/app_colors.dart @@ -20,4 +20,5 @@ class AppColors { static const Color secTitle = Color.fromARGB(255, 95, 95, 95); static const Color backDelete = Color.fromARGB(255, 254, 237,235); static const Color backEdit = Color.fromARGB(255, 237, 247, 238); + static const Color analyticsGrey = Color.fromARGB(255, 246, 246, 246); } \ No newline at end of file diff --git a/lib/core/config/http_overrides.dart b/lib/core/config/http_overrides.dart new file mode 100644 index 0000000..8d34d3f --- /dev/null +++ b/lib/core/config/http_overrides.dart @@ -0,0 +1,10 @@ +import 'dart:io'; + +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } +} \ No newline at end of file diff --git a/lib/domain/entities/comment_entity.dart b/lib/domain/entities/comment_entity.dart new file mode 100644 index 0000000..7fa8a02 --- /dev/null +++ b/lib/domain/entities/comment_entity.dart @@ -0,0 +1,45 @@ +import 'package:persian_datetime_picker/persian_datetime_picker.dart'; + +class CommentEntity { + final String id; + final String userFullName; + final String? userImage; + final String comment; + final double score; + final DateTime createdAt; + final List uploadedImageUrls; + + CommentEntity({ + required this.id, + required this.userFullName, + this.userImage, + required this.comment, + required this.score, + required this.createdAt, + this.uploadedImageUrls = const [], + }); + + factory CommentEntity.fromJson(Map json) { + final user = json['User'] as Map? ?? {}; + + final userImages = json['UserImages'] as List? ?? []; + final List images = userImages + .map((image) => (image as Map)['Url'] as String) + .toList(); + + return CommentEntity( + id: json['ID'] as String? ?? '', + userFullName: user['Name'] as String? ?? 'کاربر مهمان', + userImage: user['Image'] as String?, + comment: json['Text'] as String? ?? 'بدون نظر', + score: (json['Score'] as num? ?? 0).toDouble(), + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), + uploadedImageUrls: images, + ); + } + + String get persianDate { + final jalali = Jalali.fromDateTime(createdAt); + return jalali.formatFullDate(); + } +} \ No newline at end of file diff --git a/lib/domain/entities/sales_stats_entity.dart b/lib/domain/entities/sales_stats_entity.dart new file mode 100644 index 0000000..49a652d --- /dev/null +++ b/lib/domain/entities/sales_stats_entity.dart @@ -0,0 +1,22 @@ +class SalesStatsEntity { + final int orderL; + final int reservL; + final double amount; + final double avgDiffMinutes; + + SalesStatsEntity({ + required this.orderL, + required this.reservL, + required this.amount, + required this.avgDiffMinutes, + }); + + factory SalesStatsEntity.fromJson(Map json) { + return SalesStatsEntity( + orderL: json['orderL'] as int? ?? 0, + reservL: json['reservL'] as int? ?? 0, + amount: (json['amount'] as num? ?? 0).toDouble(), + avgDiffMinutes: (json['avgDiffMinutes'] as num? ?? 0).toDouble(), + ); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 656da74..b322843 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,12 @@ +import 'dart:io'; + import 'package:business_panel/core/config/app_colors.dart'; +import 'package:business_panel/core/config/http_overrides.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/order/bloc/order_bloc.dart'; import 'package:business_panel/presentation/pages/splash_page.dart'; +import 'package:business_panel/presentation/reservation/bloc/reservation_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; // ignore: depend_on_referenced_packages @@ -17,6 +21,7 @@ void main() async { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); + HttpOverrides.global = MyHttpOverrides(); runApp(const MyApp()); } @@ -33,6 +38,9 @@ class MyApp extends StatelessWidget { create: (context) => HomeBloc()..add(FetchDiscounts()), ), BlocProvider(create: (context) => OrderBloc()), + BlocProvider( + create: (context) => ReservationBloc()..add(FetchReservations()), + ), ], child: MaterialApp( title: 'Proxibuy', diff --git a/lib/presentation/pages/discount_manegment_page.dart b/lib/presentation/pages/discount_manegment_page.dart index c7af69b..04b7bc4 100644 --- a/lib/presentation/pages/discount_manegment_page.dart +++ b/lib/presentation/pages/discount_manegment_page.dart @@ -1,4 +1,3 @@ -// lib/presentation/pages/discount_manegment_page.dart import 'dart:async'; import 'package:business_panel/core/config/app_colors.dart'; import 'package:business_panel/domain/entities/discount_entity.dart'; @@ -32,8 +31,7 @@ class _DiscountManegmentView extends StatefulWidget { class _DiscountManegmentPageState extends State<_DiscountManegmentView> { final TextEditingController _searchController = TextEditingController(); Timer? _debounce; - int _selectedStatus = 1; // 1 for active, 0 for inactive - + int _selectedStatus = 1; @override void initState() { super.initState(); @@ -216,7 +214,6 @@ class _DiscountManegmentPageState extends State<_DiscountManegmentView> { return const Center( child: Text("هیچ تخفیفی با این مشخصات یافت نشد.")); } - // Group discounts final Map> groupedDiscounts = {}; for (var discount in state.discounts) { if (groupedDiscounts.containsKey(discount.type)) { diff --git a/lib/presentation/pages/sales_analysis_page.dart b/lib/presentation/pages/sales_analysis_page.dart new file mode 100644 index 0000000..393b736 --- /dev/null +++ b/lib/presentation/pages/sales_analysis_page.dart @@ -0,0 +1,410 @@ +import 'dart:math'; +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/sales_analysis/bloc/sales_analysis_bloc.dart'; +import 'package:business_panel/presentation/widgets/comments_section.dart'; +import 'package:business_panel/presentation/widgets/custom_app_bar_single.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:intl/intl.dart'; +import 'package:persian_datetime_picker/persian_datetime_picker.dart'; + +class SalesAnalysisPage extends StatelessWidget { + final DiscountEntity discount; + const SalesAnalysisPage({super.key, required this.discount}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SalesAnalysisBloc() + ..add(FetchSalesData( + discountId: discount.id, + date: DateTime.now(), + )), + child: _SalesAnalysisView(discount: discount), + ); + } +} + +class _SalesAnalysisView extends StatefulWidget { + final DiscountEntity discount; + const _SalesAnalysisView({required this.discount}); + + @override + State<_SalesAnalysisView> createState() => _SalesAnalysisViewState(); +} + +class _SalesAnalysisViewState extends State<_SalesAnalysisView> { + DateTime _selectedDate = DateTime.now(); + + Future _pickDate(BuildContext context) async { + final now = Jalali.now(); + Jalali? picked = await showPersianDatePicker( + context: context, + initialDate: Jalali.fromDateTime(_selectedDate), + firstDate: now.addDays(-365), + lastDate: now, + ); + + if (picked != null && context.mounted) { + setState(() { + _selectedDate = picked.toDateTime(); + }); + context.read().add(FetchSalesData( + discountId: widget.discount.id, + date: _selectedDate, + )); + } + } + + String _formatAvgTime(double avgMinutes) { + if (avgMinutes < 0) return "نامشخص"; + if (avgMinutes < 1) { + final int seconds = (avgMinutes * 60).round(); + return seconds > 0 ? "$seconds ثانیه" : "-"; + } + if (avgMinutes >= 60) { + final int hours = (avgMinutes / 60).floor(); + final int minutes = (avgMinutes % 60).round(); + String result = "$hours ساعت"; + if (minutes > 0) result += " و $minutes دقیقه"; + return result; + } + final int minutes = avgMinutes.floor(); + final int seconds = ((avgMinutes - minutes) * 60).round(); + String result = "$minutes دقیقه"; + if (seconds > 0) result += " و $seconds ثانیه"; + return result; + } + + @override + Widget build(BuildContext context) { + final int discountPercentage = (widget.discount.price > 0 && widget.discount.price > widget.discount.nPrice) + ? (((widget.discount.price - widget.discount.nPrice) / widget.discount.price) * 100).toInt() : 0; + final jalaliDate = Jalali.fromDateTime(_selectedDate); + final formattedDate = '${jalaliDate.formatter.wN} ${jalaliDate.day} ${jalaliDate.formatter.mN} ${jalaliDate.year}'; + + return Scaffold( + appBar: CustomAppBarSingle(page: "تخفیف ها"), + body: BlocListener( + listener: (context, state) { + if (state is SalesAnalysisLoaded) { + if (state.kpiStatus == KpiStatus.loading) { + context.read().add(FetchSalesStats(widget.discount.id)); + } + if (state.commentStatus == CommentStatus.loading) { + context.read().add(FetchComments(widget.discount.id)); + } + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDiscountInfo(discountPercentage), + const SizedBox(height: 32), + _buildSectionHeader("آنالیز ساعتی فروش"), + const SizedBox(height: 25), + _buildDatePickerField(context, formattedDate), + const SizedBox(height: 24), + _buildChartSection(), + const SizedBox(height: 32), + _buildSectionHeader("شاخص‌های کلیدی رزرو و فروش"), + const SizedBox(height: 20), + _buildKeyPerformanceIndicators(), + const SizedBox(height: 32), + _buildSectionHeader("نظرات کاربران"), + _buildCommentsSection(), + ], + ), + ), + ), + ); + } + + Widget _buildCommentsSection() { + return BlocBuilder( + builder: (context, state) { + if (state is SalesAnalysisLoaded) { + switch (state.commentStatus) { + case CommentStatus.loading: + case CommentStatus.initial: + return const Center(child: CircularProgressIndicator()); + case CommentStatus.failure: + return Center( + child: Text( + state.commentErrorMessage ?? 'خطا در بارگذاری نظرات', + style: const TextStyle(color: Colors.red), + ), + ); + case CommentStatus.success: + if (state.comments.isEmpty) { + return const Center(child: Text('هنوز نظری برای این تخفیف ثبت نشده است.')); + } + return CommentsSection(comments: state.comments); + } + } + return const SizedBox.shrink(); + }, + ); + } + + Widget _buildDiscountInfo(int discountPercentage) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader("تخفیف ${widget.discount.type}"), + const SizedBox(height: 10), + const Divider(height: 1), + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildProductImage(), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.discount.name, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.normal, color: AppColors.hint), + ), + const SizedBox(height: 12), + _buildPriceRow( + icon: Assets.icons.ticketDiscount, + child: Row( + children: [ + Text( + NumberFormat('#,##0').format(widget.discount.price), + style: const TextStyle(fontSize: 16, color: Colors.grey, decoration: TextDecoration.lineThrough), + ), + const SizedBox(width: 8), + if (discountPercentage > 0) + Text('($discountPercentage%)', style: const TextStyle(fontSize: 14, color: Colors.red)), + ], + ), + ), + const SizedBox(height: 12), + _buildPriceRow( + icon: Assets.icons.cardPos, + child: Text( + "${NumberFormat('#,##0').format(widget.discount.nPrice)} تومان", + style: const TextStyle(fontSize: 16, color: Colors.red, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildDatePickerField(BuildContext context, String formattedDate) { + return InkWell( + onTap: () => _pickDate(context), + child: InputDecorator( + decoration: InputDecoration( + labelText: "انتخاب تاریخ", + suffixIcon: Padding( + padding: const EdgeInsets.all(12.0), + child: SvgPicture.asset(Assets.icons.calendarSearch, colorFilter: const ColorFilter.mode(AppColors.active, BlendMode.srcIn)), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.border), + ), + ), + child: Text(formattedDate, style: const TextStyle(fontSize: 16)), + ), + ); + } + + Widget _buildKeyPerformanceIndicators() { + return BlocBuilder( + buildWhen: (previous, current) => current is SalesAnalysisLoaded, + builder: (context, state) { + if (state is SalesAnalysisLoaded) { + switch (state.kpiStatus) { + case KpiStatus.loading: + case KpiStatus.initial: + return const Center(child: CircularProgressIndicator()); + case KpiStatus.failure: + return Center(child: Text(state.kpiErrorMessage ?? 'خطا', style: const TextStyle(color: Colors.red))); + case KpiStatus.success: + if (state.salesStats == null) { + return const Center(child: Text('آماری برای نمایش وجود ندارد.')); + } + final stats = state.salesStats!; + final conversionRate = stats.reservL > 0 ? (stats.orderL / stats.reservL) * 100 : 0.0; + final formattedAmount = NumberFormat('#,##0').format(stats.amount); + final formattedAvgTime = _formatAvgTime(stats.avgDiffMinutes); + + return Container( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Column( + children: [ + _buildKpiRow('میانگین زمان رزرو تا خرید', formattedAvgTime, isOdd: true), + _buildKpiRow('تعداد رزرو محصول تا این لحظه', '${stats.reservL}', isOdd: false), + _buildKpiRow('تعداد خرید محصول تا این لحظه', '${stats.orderL}', isOdd: true), + _buildKpiRow('نرخ تبدیل', '${conversionRate.toStringAsFixed(1)}٪', isOdd: false), + _buildKpiRow('میزان فروش تا این لحظه', '$formattedAmount تومان', isOdd: true), + ], + ), + ), + ); + } + } + return const Center(child: CircularProgressIndicator()); + }, + ); + } + + Widget _buildKpiRow(String title, String value, {required bool isOdd}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: isOdd ? AppColors.analyticsGrey : Colors.white, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: const TextStyle(color: Colors.black, fontSize: 14)), + Text(value, style: const TextStyle(color: AppColors.active, fontWeight: FontWeight.normal, fontSize: 14)), + ], + ), + ); + } + + Widget _buildChartSection() { + return BlocBuilder( + builder: (context, state) { + if (state is SalesAnalysisLoading || state is SalesAnalysisInitial) { + return const Center(child: CircularProgressIndicator()); + } + if (state is SalesAnalysisError) { + return Center(child: Text('خطا: ${state.message}', style: const TextStyle(color: Colors.red))); + } + if (state is SalesAnalysisLoaded) { + final List barGroups = []; + final data = state.salesData; + double maxY = 0; + data.forEach((key, value) { + final num numericValue = value; + if (numericValue > maxY) maxY = numericValue.toDouble(); + }); + maxY = (maxY == 0) ? 5 : (maxY * 1.2); + + int i = 0; + data.forEach((key, value) { + final num numericValue = value; + barGroups.add( + BarChartGroupData(x: i++, barRods: [ + BarChartRodData( + toY: numericValue.toDouble(), + gradient: LinearGradient(colors: [Colors.blue.shade700, AppColors.primary], begin: Alignment.bottomCenter, end: Alignment.topCenter), + width: 22, + borderRadius: const BorderRadius.all(Radius.circular(6)), + backDrawRodData: BackgroundBarChartRodData(show: true, toY: maxY, color: Colors.grey.shade200), + ), + ]), + ); + }); + + return SizedBox( + height: 300, + child: BarChart( + BarChartData( + maxY: maxY, + alignment: BarChartAlignment.spaceAround, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final key = data.keys.elementAt(group.x); + return BarTooltipItem('$key\n', const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 14), + children: [TextSpan(text: (rod.toY).toInt().toString(), style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500))], + ); + }, + ), + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (double value, TitleMeta meta) { + final titles = data.keys.toList(); + if (value.toInt() >= titles.length) return const SizedBox(); + // *** FIX IS HERE: Added meta *** + return SideTitleWidget(meta: meta, space: 15.0, angle: -pi / 2, child: Text(titles[value.toInt()], style: const TextStyle(fontSize: 10))); + }, + reservedSize: 38, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: (maxY / 5).ceilToDouble(), + getTitlesWidget: (double value, TitleMeta meta) { + if (value == 0 || value > maxY) return const SizedBox(); + // *** FIX IS HERE: Added meta *** + return SideTitleWidget(meta: meta, child: Text(value.toInt().toString(), style: const TextStyle(fontSize: 10))); + }, + ), + ), + ), + borderData: FlBorderData(show: false), + gridData: FlGridData(show: true, drawVerticalLine: false, horizontalInterval: (maxY / 5).ceilToDouble(), getDrawingHorizontalLine: (value) => const FlLine(color: Color(0xffe7e8ec), strokeWidth: 1)), + barGroups: barGroups, + ), + ), + ); + } + return const Center(child: Text("برای مشاهده آمار، تاریخ را انتخاب کنید.")); + }, + ); + } + + Widget _buildSectionHeader(String title) { + return Row(children: [ + Container(width: 4, height: 24, decoration: BoxDecoration(color: AppColors.active, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ]); + } + + Widget _buildPriceRow({required String icon, required Widget child}) { + return Row(children: [ + SvgPicture.asset(icon, width: 20, colorFilter: const ColorFilter.mode(Color.fromARGB(255, 157, 157, 155), BlendMode.srcIn)), + const SizedBox(width: 8), + child, + ]); + } + + Widget _buildProductImage() { + return ClipRRect( + borderRadius: BorderRadius.circular(15), + child: (widget.discount.images.isNotEmpty && widget.discount.images.first.isNotEmpty) + ? Image.network(widget.discount.images.first, width: 120, height: 120, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => _buildImagePlaceholder()) + : _buildImagePlaceholder(), + ); + } + + Widget _buildImagePlaceholder() { + return Container( + width: 120, + height: 120, + decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(15)), + child: const Icon(Icons.store, color: Colors.grey, size: 60), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/sales_analysis/bloc/sales_analysis_bloc.dart b/lib/presentation/sales_analysis/bloc/sales_analysis_bloc.dart new file mode 100644 index 0000000..6721f67 --- /dev/null +++ b/lib/presentation/sales_analysis/bloc/sales_analysis_bloc.dart @@ -0,0 +1,123 @@ +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/comment_entity.dart'; +import 'package:business_panel/domain/entities/sales_stats_entity.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; + +part 'sales_analysis_event.dart'; +part 'sales_analysis_state.dart'; + +class SalesAnalysisBloc extends Bloc { + final Dio _dio = Dio(); + final TokenStorageService _tokenStorage = TokenStorageService(); + + SalesAnalysisBloc() : super(SalesAnalysisInitial()) { + on((event, emit) async { + emit(SalesAnalysisLoading()); + try { + final token = await _tokenStorage.getAccessToken(); + if (token == null) { + emit(const SalesAnalysisError("خطای احراز هویت.")); + return; + } + + final response = await _dio.post( + ApiConfig.getOrderStats, + data: { + 'Discount': event.discountId, + 'Start': DateFormat('yyyy-MM-dd').format(event.date) + }, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && response.data['data'] != null) { + emit(SalesAnalysisLoaded( + salesData: response.data['data'], + kpiStatus: KpiStatus.loading, + commentStatus: CommentStatus.loading, + )); + } else { + emit(SalesAnalysisError( + response.data['message'] ?? 'خطا در دریافت اطلاعات نمودار')); + } + } catch (e) { + emit(SalesAnalysisError('خطای پیش‌بینی نشده: ${e.toString()}')); + } + }); + + on((event, emit) async { + if (state is! SalesAnalysisLoaded) return; + final currentState = state as SalesAnalysisLoaded; + + try { + final token = await _tokenStorage.getAccessToken(); + final response = await _dio.get( + ApiConfig.getOrderStatus(event.discountId), + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + if (response.statusCode == 200 && response.data['data'] != null) { + final stats = SalesStatsEntity.fromJson(response.data['data']); + emit(currentState.copyWith( + kpiStatus: KpiStatus.success, salesStats: stats)); + } else { + emit(currentState.copyWith( + kpiStatus: KpiStatus.failure, + kpiErrorMessage: response.data['message'])); + } + } catch (e) { + emit(currentState.copyWith( + kpiStatus: KpiStatus.failure, + kpiErrorMessage: 'خطای شبکه در دریافت آمار')); + } + }); + + on((event, emit) async { + debugPrint("[FetchComments] Event Started"); + if (state is! SalesAnalysisLoaded) return; + final currentState = state as SalesAnalysisLoaded; + + try { + final token = await _tokenStorage.getAccessToken(); + final url = ApiConfig.getComments(event.discountId); + debugPrint("[FetchComments] Calling URL: $url"); + + final response = await _dio.get( + url, + options: Options(headers: {'Authorization': 'Bearer $token'}), + ); + + debugPrint("[FetchComments] Raw Response Data: ${response.data}"); + + if (response.statusCode == 200 && response.data['data'] != null) { + final List commentsList = response.data['data']['comments'] ?? []; + + final comments = commentsList.map((json) => CommentEntity.fromJson(json)).toList(); + debugPrint("[FetchComments] Success: Found ${comments.length} comments."); + + emit(currentState.copyWith( + commentStatus: CommentStatus.success, comments: comments)); + } else { + debugPrint("[FetchComments] Failure: Server returned status ${response.statusCode}"); + emit(currentState.copyWith( + commentStatus: CommentStatus.failure, + commentErrorMessage: response.data['message'] ?? 'خطا در دریافت پاسخ از سرور')); + } + } on DioException catch (e) { + debugPrint("[FetchComments] DioException: ${e.message}"); + debugPrint("[FetchComments] DioException Response: ${e.response?.data}"); + emit(currentState.copyWith( + commentStatus: CommentStatus.failure, + commentErrorMessage: 'خطای شبکه در دریافت نظرات')); + } catch (e) { + debugPrint("[FetchComments] Generic Exception: ${e.toString()}"); + emit(currentState.copyWith( + commentStatus: CommentStatus.failure, + commentErrorMessage: 'خطای پیش‌بینی نشده در پردازش نظرات')); + } + }); + } +} \ No newline at end of file diff --git a/lib/presentation/sales_analysis/bloc/sales_analysis_event.dart b/lib/presentation/sales_analysis/bloc/sales_analysis_event.dart new file mode 100644 index 0000000..e75346a --- /dev/null +++ b/lib/presentation/sales_analysis/bloc/sales_analysis_event.dart @@ -0,0 +1,22 @@ +part of 'sales_analysis_bloc.dart'; + +abstract class SalesAnalysisEvent {} + +class FetchSalesData extends SalesAnalysisEvent { + final String discountId; + final DateTime date; + + FetchSalesData({required this.discountId, required this.date}); +} + +class FetchSalesStats extends SalesAnalysisEvent { + final String discountId; + + FetchSalesStats(this.discountId); +} + +class FetchComments extends SalesAnalysisEvent { + final String discountId; + + FetchComments(this.discountId); +} \ No newline at end of file diff --git a/lib/presentation/sales_analysis/bloc/sales_analysis_state.dart b/lib/presentation/sales_analysis/bloc/sales_analysis_state.dart new file mode 100644 index 0000000..b88f0b6 --- /dev/null +++ b/lib/presentation/sales_analysis/bloc/sales_analysis_state.dart @@ -0,0 +1,57 @@ +part of 'sales_analysis_bloc.dart'; + +enum KpiStatus { initial, loading, success, failure } +enum CommentStatus { initial, loading, success, failure } + +abstract class SalesAnalysisState { + const SalesAnalysisState(); +} + +class SalesAnalysisInitial extends SalesAnalysisState {} + +class SalesAnalysisLoading extends SalesAnalysisState {} + +class SalesAnalysisLoaded extends SalesAnalysisState { + final Map salesData; + final SalesStatsEntity? salesStats; + final KpiStatus kpiStatus; + final String? kpiErrorMessage; + final List comments; + final CommentStatus commentStatus; + final String? commentErrorMessage; + + const SalesAnalysisLoaded({ + required this.salesData, + this.salesStats, + this.kpiStatus = KpiStatus.initial, + this.kpiErrorMessage, + this.comments = const [], + this.commentStatus = CommentStatus.initial, + this.commentErrorMessage, + }); + + SalesAnalysisLoaded copyWith({ + Map? salesData, + SalesStatsEntity? salesStats, + KpiStatus? kpiStatus, + String? kpiErrorMessage, + List? comments, + CommentStatus? commentStatus, + String? commentErrorMessage, + }) { + return SalesAnalysisLoaded( + salesData: salesData ?? this.salesData, + salesStats: salesStats ?? this.salesStats, + kpiStatus: kpiStatus ?? this.kpiStatus, + kpiErrorMessage: kpiErrorMessage ?? this.kpiErrorMessage, + comments: comments ?? this.comments, + commentStatus: commentStatus ?? this.commentStatus, + commentErrorMessage: commentErrorMessage ?? this.commentErrorMessage, + ); + } +} + +class SalesAnalysisError extends SalesAnalysisState { + final String message; + const SalesAnalysisError(this.message); +} \ No newline at end of file diff --git a/lib/presentation/widgets/analytics_discount_card.dart b/lib/presentation/widgets/analytics_discount_card.dart index 6f3a19c..68a8b75 100644 --- a/lib/presentation/widgets/analytics_discount_card.dart +++ b/lib/presentation/widgets/analytics_discount_card.dart @@ -3,6 +3,7 @@ import 'package:business_panel/domain/entities/discount_entity.dart'; import 'package:business_panel/gen/assets.gen.dart'; import 'package:business_panel/presentation/discount_management/bloc/discount_management_bloc.dart'; import 'package:business_panel/presentation/pages/add_discount_page.dart'; +import 'package:business_panel/presentation/pages/sales_analysis_page.dart'; import 'package:business_panel/presentation/widgets/delete_confirmation_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -50,9 +51,6 @@ class AnalyticsDiscountCard extends StatelessWidget { ), ), ), - const SizedBox(height: 15), - const Divider(height: 1), - const SizedBox(height: 20), ], ); } @@ -97,17 +95,26 @@ class AnalyticsDiscountCard extends StatelessWidget { textColor: Colors.grey.shade600), const SizedBox(height: 5), if (discount.endDate == null) - _buildStatusText('تاریخ نامعتبر', Colors.orange) + _buildStatusText('تاریخ نامعتبر', Colors.orange) else if (remaining.isNegative) _buildExpiredDateRange() else _buildCountdownSection(remaining), const SizedBox(height: 10), - _buildInfoRow( - icon: Assets.icons.chart, - text: "آنالیز فروش", - textColor: AppColors.active, - isBold: true, + InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SalesAnalysisPage(discount: discount), + ), + ); + }, + child: _buildInfoRow( + icon: Assets.icons.chart, + text: "آنالیز فروش", + textColor: AppColors.active, + isBold: true, + ), ), ], ), diff --git a/lib/presentation/widgets/comments_section.dart b/lib/presentation/widgets/comments_section.dart new file mode 100644 index 0000000..c863fc2 --- /dev/null +++ b/lib/presentation/widgets/comments_section.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:business_panel/domain/entities/comment_entity.dart'; +import 'package:business_panel/presentation/widgets/user_comment_card.dart'; + +class CommentsSection extends StatelessWidget { + final List comments; + + const CommentsSection({super.key, required this.comments}); + + @override + Widget build(BuildContext context) { + if (comments.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + Row( + children: [ + // SvgPicture.asset(Assets.icons.line2), + const SizedBox(width: 8), + // const Text('نظرات کاربران', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ], + ), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: comments.length, + itemBuilder: (context, index) { + return UserCommentCard(comment: comments[index]); + }, + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/presentation/widgets/custom_app_bar.dart b/lib/presentation/widgets/custom_app_bar.dart index b1f5aa9..47d0361 100644 --- a/lib/presentation/widgets/custom_app_bar.dart +++ b/lib/presentation/widgets/custom_app_bar.dart @@ -1,9 +1,9 @@ -// lib/presentation/widgets/custom_app_bar.dart import 'package:business_panel/core/config/app_colors.dart'; import 'package:business_panel/gen/assets.gen.dart'; import 'package:business_panel/presentation/home/bloc/home_bloc.dart'; import 'package:business_panel/presentation/pages/discount_manegment_page.dart'; import 'package:business_panel/presentation/pages/reserve_manegment_page.dart'; +import 'package:business_panel/presentation/reservation/bloc/reservation_bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -27,7 +27,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { ), child: SafeArea( child: Padding( - padding: EdgeInsets.all(8), + padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -38,13 +38,29 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { builder: (context, state) { final count = state is HomeLoaded ? state.discounts.length : 0; - return Row( + return Stack( + alignment: Alignment.center, children: [ - Stack( - alignment: Alignment.center, - children: [ - IconButton( - onPressed: () { + IconButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const DiscountManegmentPage(), + ), + ); + }, + icon: SvgPicture.asset( + Assets.icons.discountShape, + color: Colors.black, + ), + ), + if (count > 0) + Positioned( + top: 2, + right: 6, + child: GestureDetector( + onTap: () { Navigator.of(context).push( MaterialPageRoute( builder: (_) => @@ -52,106 +68,90 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { ), ); }, - icon: SvgPicture.asset( - Assets.icons.discountShape, - color: Colors.black, - ), - ), - if (count > 0) - Positioned( - top: 2, - right: 6, - child: GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - const DiscountManegmentPage(), - ), - ); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - constraints: const BoxConstraints( - minWidth: 16, - minHeight: 16, - ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: Text( - '$count', - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints( + minWidth: 16, + minHeight: 16, + ), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, ), + textAlign: TextAlign.center, ), ), ), - ], + ), + ), + ], + ); + }, + ), + BlocBuilder( + builder: (context, state) { + final count = state is ReservationLoaded + ? state.reservations.length + : 0; + return Stack( + alignment: Alignment.center, + children: [ + IconButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ReserveManegmment(), + ), + ); + }, + icon: SvgPicture.asset(Assets.icons.scanBarcode), ), - Stack( - alignment: Alignment.center, - children: [ - IconButton( - onPressed: () { + if (count > 0) + Positioned( + top: 2, + right: 6, + child: GestureDetector( + onTap: () { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => - const ReserveManegmment(), + builder: (_) => const ReserveManegmment(), ), ); }, - icon: - SvgPicture.asset(Assets.icons.scanBarcode), - ), - if (count > 0) - Positioned( - top: 2, - right: 6, - child: GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - const ReserveManegmment(), - ), - ); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: AppColors.selectedImg, - shape: BoxShape.circle, - ), - constraints: const BoxConstraints( - minWidth: 16, - minHeight: 16, - ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: Text( - '$count', - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: AppColors.selectedImg, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints( + minWidth: 16, + minHeight: 16, + ), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, ), + textAlign: TextAlign.center, ), ), ), - ], - ), + ), + ), ], ); }, diff --git a/lib/presentation/widgets/reservation_card.dart b/lib/presentation/widgets/reservation_card.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/presentation/widgets/user_comment_card.dart b/lib/presentation/widgets/user_comment_card.dart new file mode 100644 index 0000000..b8da747 --- /dev/null +++ b/lib/presentation/widgets/user_comment_card.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:business_panel/core/config/app_colors.dart'; +import 'package:business_panel/gen/assets.gen.dart'; +import 'package:business_panel/domain/entities/comment_entity.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class CustomStarRating extends StatelessWidget { + final double rating; + final int starCount; + final double size; + + const CustomStarRating({ + super.key, + required this.rating, + this.starCount = 5, + this.size = 10.0, + }); + + @override + Widget build(BuildContext context) { + List stars = []; + double remaining = rating; + + for (int i = 0; i < starCount; i++) { + if (remaining >= 1) { + stars.add(_buildStar(Assets.icons.starFill)); + remaining -= 1; + } else if (remaining >= 0.5) { + stars.add(_buildStar(Assets.icons.starHalf)); + remaining = 0; + } else { + stars.add(_buildStar(Assets.icons.star)); + } + } + return Row(mainAxisSize: MainAxisSize.min, children: stars); + } + + Widget _buildStar(String asset) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: SvgPicture.asset( + asset, + width: size, + height: size, + colorFilter: const ColorFilter.mode(Colors.amber, BlendMode.srcIn), + ), + ); + } +} + +class UserCommentCard extends StatelessWidget { + final CommentEntity comment; + + const UserCommentCard({super.key, required this.comment}); + + @override + Widget build(BuildContext context) { + final String formattedDate = comment.persianDate; + + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + comment.userFullName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const Spacer(), + Text( + formattedDate, + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + ], + ), + const SizedBox(height: 4), + CustomStarRating(rating: comment.score, size: 18), + const SizedBox(height: 10), + Text( + '"${comment.comment}"', + style: const TextStyle(fontSize: 14, height: 1.6, color: Colors.black87), + ), + if (comment.uploadedImageUrls.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'عکس‌های آپلود شده توسط کاربر', + style: TextStyle( + fontWeight: FontWeight.normal, + color: AppColors.active, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 80, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: comment.uploadedImageUrls.length, + itemBuilder: (context, index) { + final imageUrl = comment.uploadedImageUrls[index]; + return Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.network( + imageUrl, + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.grey.shade200, + child: const Icon(Icons.error)), + ), + ), + ); + }, + ), + ) + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 52647f4..a3f38a1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -505,6 +505,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 19f86e2..4d2aa53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: vibration: ^3.1.3 audioplayers: ^6.5.0 jwt_decoder: ^2.0.1 + fl_chart: ^1.0.0 dev_dependencies: