410 lines
17 KiB
Dart
410 lines
17 KiB
Dart
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),
|
||
);
|
||
}
|
||
} |