added analytics mangment
This commit is contained in:
parent
205a58359c
commit
9a1550ac24
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: 'خطای پیشبینی نشده در پردازش نظرات'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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,12 +101,21 @@ class AnalyticsDiscountCard extends StatelessWidget {
|
||||||
else
|
else
|
||||||
_buildCountdownSection(remaining),
|
_buildCountdownSection(remaining),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildInfoRow(
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SalesAnalysisPage(discount: discount),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _buildInfoRow(
|
||||||
icon: Assets.icons.chart,
|
icon: Assets.icons.chart,
|
||||||
text: "آنالیز فروش",
|
text: "آنالیز فروش",
|
||||||
textColor: AppColors.active,
|
textColor: AppColors.active,
|
||||||
isBold: true,
|
isBold: true,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,9 +38,7 @@ 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(
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -96,21 +94,26 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Stack(
|
BlocBuilder<ReservationBloc, ReservationState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final count = state is ReservationLoaded
|
||||||
|
? state.reservations.length
|
||||||
|
: 0;
|
||||||
|
return Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) => const ReserveManegmment(),
|
||||||
const ReserveManegmment(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon:
|
icon: SvgPicture.asset(Assets.icons.scanBarcode),
|
||||||
SvgPicture.asset(Assets.icons.scanBarcode),
|
|
||||||
),
|
),
|
||||||
if (count > 0)
|
if (count > 0)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|
@ -120,8 +123,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) => const ReserveManegmment(),
|
||||||
const ReserveManegmment(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -151,8 +153,6 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue