proxibuy_bussiness/lib/presentation/pages/sales_analysis_page.dart

410 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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