added analytics mangment

This commit is contained in:
mohamadmahdi jebeli 2025-08-11 13:26:01 +03:30
parent 205a58359c
commit 9a1550ac24
18 changed files with 997 additions and 142 deletions

View File

@ -4,61 +4,34 @@ class ApiConfig {
// Base URL for the API // Base URL for the API
static const String baseUrl = 'https://fartak.liara.run'; static const String baseUrl = 'https://fartak.liara.run';
// Base URL for the Comments API
static const String proxyBuyBaseUrl = 'https://proxybuy.liara.run';
// ========== Auth Endpoints ========== // ========== Auth Endpoints ==========
/// Endpoint to send OTP code to the user.
/// Method: POST
/// Body: {'Phone': phoneNumber, 'Code': countryCode}
static const String sendOtp = '$baseUrl/login/sendcode'; 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'; 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'; static const String checkShopStatus = '$baseUrl/shop/get';
// ========== Store Endpoints ========== // ========== Store Endpoints ==========
/// Endpoint to add a new store.
/// Method: POST
/// Body: FormData
/// Headers: {'Authorization': 'Bearer <token>'}
static const String addStore = '$baseUrl/shop/add'; static const String addStore = '$baseUrl/shop/add';
// ========== Discount Endpoints ========== // ========== 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 addDiscount = '$baseUrl/discount/add';
static const String getDiscounts = '$baseUrl/discount/get'; static const String getDiscounts = '$baseUrl/discount/get';
static const String getActiveDiscounts = '$baseUrl/discount/get?status=1'; static const String getActiveDiscounts = '$baseUrl/discount/get?status=1';
/// Endpoint to get a single discount by its ID.
/// Method: GET
/// Headers: {'Authorization': 'Bearer <token>'}
static String getDiscountById(String id) => '$baseUrl/discount/get/$id'; static String getDiscountById(String id) => '$baseUrl/discount/get/$id';
/// Endpoint to edit an existing discount.
/// Method: POST
/// Body: FormData
/// Headers: {'Authorization': 'Bearer <token>'}
static String editDiscount(String id) => '$baseUrl/discount/edit/$id'; static String editDiscount(String id) => '$baseUrl/discount/edit/$id';
static String deleteDiscount(String id) => '$baseUrl/discount/delete/$id'; static String deleteDiscount(String id) => '$baseUrl/discount/delete/$id';
// ========== Order Endpoints ========== // ========== Order Endpoints ==========
/// Endpoint to add a new order.
/// Method: POST
/// Body: {'Discount': discountId, 'User': userId}
/// Headers: {'Authorization': 'Bearer <token>'}
static const String addOrder = '$baseUrl/order/add'; 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 ========== // ========== Reservation Endpoints ==========
/// Endpoint to get reservations.
/// Method: GET
/// Headers: {'Authorization': 'Bearer <token>'}
static const String getReservations = '$baseUrl/reservation/get'; static const String getReservations = '$baseUrl/reservation/get';
// ========== Comment Endpoints ==========
static String getComments(String discountId) => '$baseUrl/comment/get/$discountId';
} }

View File

@ -20,4 +20,5 @@ class AppColors {
static const Color secTitle = Color.fromARGB(255, 95, 95, 95); static const Color secTitle = Color.fromARGB(255, 95, 95, 95);
static const Color backDelete = Color.fromARGB(255, 254, 237,235); static const Color backDelete = Color.fromARGB(255, 254, 237,235);
static const Color backEdit = Color.fromARGB(255, 237, 247, 238); static const Color backEdit = Color.fromARGB(255, 237, 247, 238);
static const Color analyticsGrey = Color.fromARGB(255, 246, 246, 246);
} }

View File

@ -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;
}
}

View File

@ -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<String> 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<String, dynamic> json) {
final user = json['User'] as Map<String, dynamic>? ?? {};
final userImages = json['UserImages'] as List<dynamic>? ?? [];
final List<String> images = userImages
.map((image) => (image as Map<String, dynamic>)['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();
}
}

View File

@ -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<String, dynamic> 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(),
);
}
}

View File

@ -1,8 +1,12 @@
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/core/config/http_overrides.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/home/bloc/home_bloc.dart';
import 'package:business_panel/presentation/order/bloc/order_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/pages/splash_page.dart';
import 'package:business_panel/presentation/reservation/bloc/reservation_bloc.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';
// ignore: depend_on_referenced_packages // ignore: depend_on_referenced_packages
@ -17,6 +21,7 @@ void main() async {
await Firebase.initializeApp( await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, options: DefaultFirebaseOptions.currentPlatform,
); );
HttpOverrides.global = MyHttpOverrides();
runApp(const MyApp()); runApp(const MyApp());
} }
@ -33,6 +38,9 @@ class MyApp extends StatelessWidget {
create: (context) => HomeBloc()..add(FetchDiscounts()), create: (context) => HomeBloc()..add(FetchDiscounts()),
), ),
BlocProvider(create: (context) => OrderBloc()), BlocProvider(create: (context) => OrderBloc()),
BlocProvider(
create: (context) => ReservationBloc()..add(FetchReservations()),
),
], ],
child: MaterialApp( child: MaterialApp(
title: 'Proxibuy', title: 'Proxibuy',

View File

@ -1,4 +1,3 @@
// lib/presentation/pages/discount_manegment_page.dart
import 'dart:async'; import 'dart:async';
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_entity.dart'; import 'package:business_panel/domain/entities/discount_entity.dart';
@ -32,8 +31,7 @@ class _DiscountManegmentView extends StatefulWidget {
class _DiscountManegmentPageState extends State<_DiscountManegmentView> { class _DiscountManegmentPageState extends State<_DiscountManegmentView> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
Timer? _debounce; Timer? _debounce;
int _selectedStatus = 1; // 1 for active, 0 for inactive int _selectedStatus = 1;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -216,7 +214,6 @@ class _DiscountManegmentPageState extends State<_DiscountManegmentView> {
return const Center( return const Center(
child: Text("هیچ تخفیفی با این مشخصات یافت نشد.")); child: Text("هیچ تخفیفی با این مشخصات یافت نشد."));
} }
// Group discounts
final Map<String, List<DiscountEntity>> groupedDiscounts = {}; final Map<String, List<DiscountEntity>> groupedDiscounts = {};
for (var discount in state.discounts) { for (var discount in state.discounts) {
if (groupedDiscounts.containsKey(discount.type)) { if (groupedDiscounts.containsKey(discount.type)) {

View File

@ -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<void> _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<SalesAnalysisBloc>().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<SalesAnalysisBloc, SalesAnalysisState>(
listener: (context, state) {
if (state is SalesAnalysisLoaded) {
if (state.kpiStatus == KpiStatus.loading) {
context.read<SalesAnalysisBloc>().add(FetchSalesStats(widget.discount.id));
}
if (state.commentStatus == CommentStatus.loading) {
context.read<SalesAnalysisBloc>().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<SalesAnalysisBloc, SalesAnalysisState>(
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<SalesAnalysisBloc, SalesAnalysisState>(
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<SalesAnalysisBloc, SalesAnalysisState>(
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<BarChartGroupData> 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>[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),
);
}
}

View File

@ -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<SalesAnalysisEvent, SalesAnalysisState> {
final Dio _dio = Dio();
final TokenStorageService _tokenStorage = TokenStorageService();
SalesAnalysisBloc() : super(SalesAnalysisInitial()) {
on<FetchSalesData>((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<FetchSalesStats>((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<FetchComments>((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<dynamic> 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: 'خطای پیش‌بینی نشده در پردازش نظرات'));
}
});
}
}

View File

@ -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);
}

View File

@ -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<String, dynamic> salesData;
final SalesStatsEntity? salesStats;
final KpiStatus kpiStatus;
final String? kpiErrorMessage;
final List<CommentEntity> 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<String, dynamic>? salesData,
SalesStatsEntity? salesStats,
KpiStatus? kpiStatus,
String? kpiErrorMessage,
List<CommentEntity>? 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);
}

View File

@ -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/gen/assets.gen.dart';
import 'package:business_panel/presentation/discount_management/bloc/discount_management_bloc.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/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:business_panel/presentation/widgets/delete_confirmation_dialog.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';
@ -50,9 +51,6 @@ class AnalyticsDiscountCard extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: 15),
const Divider(height: 1),
const SizedBox(height: 20),
], ],
); );
} }
@ -103,11 +101,20 @@ class AnalyticsDiscountCard extends StatelessWidget {
else else
_buildCountdownSection(remaining), _buildCountdownSection(remaining),
const SizedBox(height: 10), const SizedBox(height: 10),
_buildInfoRow( InkWell(
icon: Assets.icons.chart, onTap: () {
text: "آنالیز فروش", Navigator.of(context).push(
textColor: AppColors.active, MaterialPageRoute(
isBold: true, builder: (_) => SalesAnalysisPage(discount: discount),
),
);
},
child: _buildInfoRow(
icon: Assets.icons.chart,
text: "آنالیز فروش",
textColor: AppColors.active,
isBold: true,
),
), ),
], ],
), ),

View File

@ -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<CommentEntity> 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]);
},
),
],
);
}
}

View File

@ -1,9 +1,9 @@
// lib/presentation/widgets/custom_app_bar.dart
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/home/bloc/home_bloc.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/discount_manegment_page.dart';
import 'package:business_panel/presentation/pages/reserve_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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@ -27,7 +27,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
), ),
child: SafeArea( child: SafeArea(
child: Padding( child: Padding(
padding: EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -38,13 +38,29 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
builder: (context, state) { builder: (context, state) {
final count = final count =
state is HomeLoaded ? state.discounts.length : 0; state is HomeLoaded ? state.discounts.length : 0;
return Row( return Stack(
alignment: Alignment.center,
children: [ children: [
Stack( IconButton(
alignment: Alignment.center, onPressed: () {
children: [ Navigator.of(context).push(
IconButton( MaterialPageRoute(
onPressed: () { 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( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => builder: (_) =>
@ -52,106 +68,90 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
), ),
); );
}, },
icon: SvgPicture.asset( child: Container(
Assets.icons.discountShape, padding: const EdgeInsets.all(4),
color: Colors.black, decoration: const BoxDecoration(
), color: Colors.red,
), shape: BoxShape.circle,
if (count > 0) ),
Positioned( constraints: const BoxConstraints(
top: 2, minWidth: 16,
right: 6, minHeight: 16,
child: GestureDetector( ),
onTap: () { child: Padding(
Navigator.of(context).push( padding: const EdgeInsets.all(2.0),
MaterialPageRoute( child: Text(
builder: (_) => '$count',
const DiscountManegmentPage(), style: const TextStyle(
), color: Colors.white,
); fontSize: 10,
}, fontWeight: FontWeight.bold,
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,
),
), ),
textAlign: TextAlign.center,
), ),
), ),
), ),
], ),
),
],
);
},
),
BlocBuilder<ReservationBloc, ReservationState>(
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( if (count > 0)
alignment: Alignment.center, Positioned(
children: [ top: 2,
IconButton( right: 6,
onPressed: () { child: GestureDetector(
onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => builder: (_) => const ReserveManegmment(),
const ReserveManegmment(),
), ),
); );
}, },
icon: child: Container(
SvgPicture.asset(Assets.icons.scanBarcode), padding: const EdgeInsets.all(4),
), decoration: const BoxDecoration(
if (count > 0) color: AppColors.selectedImg,
Positioned( shape: BoxShape.circle,
top: 2, ),
right: 6, constraints: const BoxConstraints(
child: GestureDetector( minWidth: 16,
onTap: () { minHeight: 16,
Navigator.of(context).push( ),
MaterialPageRoute( child: Padding(
builder: (_) => padding: const EdgeInsets.all(2.0),
const ReserveManegmment(), child: Text(
), '$count',
); style: const TextStyle(
}, color: Colors.white,
child: Container( fontSize: 10,
padding: const EdgeInsets.all(4), fontWeight: FontWeight.bold,
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,
),
), ),
textAlign: TextAlign.center,
), ),
), ),
), ),
], ),
), ),
], ],
); );
}, },

View File

@ -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<Widget> 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)),
),
),
);
},
),
)
],
],
),
),
);
}
}

View File

@ -505,6 +505,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter

View File

@ -57,6 +57,7 @@ dependencies:
vibration: ^3.1.3 vibration: ^3.1.3
audioplayers: ^6.5.0 audioplayers: ^6.5.0
jwt_decoder: ^2.0.1 jwt_decoder: ^2.0.1
fl_chart: ^1.0.0
dev_dependencies: dev_dependencies: