added analytics mangment
This commit is contained in:
parent
205a58359c
commit
9a1550ac24
|
|
@ -4,61 +4,34 @@ class ApiConfig {
|
|||
|
||||
// Base URL for the API
|
||||
static const String baseUrl = 'https://fartak.liara.run';
|
||||
// Base URL for the Comments API
|
||||
static const String proxyBuyBaseUrl = 'https://proxybuy.liara.run';
|
||||
|
||||
|
||||
// ========== Auth Endpoints ==========
|
||||
/// Endpoint to send OTP code to the user.
|
||||
/// Method: POST
|
||||
/// Body: {'Phone': phoneNumber, 'Code': countryCode}
|
||||
static const String sendOtp = '$baseUrl/login/sendcode';
|
||||
|
||||
/// Endpoint to verify the OTP code.
|
||||
/// Method: POST
|
||||
/// Body: {'Phone': phoneNumber, 'Code': countryCode, 'OTP': otp}
|
||||
static const String verifyOtp = '$baseUrl/login/getcode';
|
||||
|
||||
/// Endpoint to check if the user has a registered shop.
|
||||
/// Method: GET
|
||||
/// Headers: {'Authorization': 'Bearer <token>'}
|
||||
static const String checkShopStatus = '$baseUrl/shop/get';
|
||||
|
||||
// ========== Store Endpoints ==========
|
||||
/// Endpoint to add a new store.
|
||||
/// Method: POST
|
||||
/// Body: FormData
|
||||
/// Headers: {'Authorization': 'Bearer <token>'}
|
||||
static const String addStore = '$baseUrl/shop/add';
|
||||
|
||||
// ========== 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 getDiscounts = '$baseUrl/discount/get';
|
||||
static const String getActiveDiscounts = '$baseUrl/discount/get?status=1';
|
||||
|
||||
/// Endpoint to get a single discount by its ID.
|
||||
/// Method: GET
|
||||
/// Headers: {'Authorization': 'Bearer <token>'}
|
||||
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 deleteDiscount(String id) => '$baseUrl/discount/delete/$id';
|
||||
|
||||
// ========== Order Endpoints ==========
|
||||
/// Endpoint to add a new order.
|
||||
/// Method: POST
|
||||
/// Body: {'Discount': discountId, 'User': userId}
|
||||
/// Headers: {'Authorization': 'Bearer <token>'}
|
||||
static const String addOrder = '$baseUrl/order/add';
|
||||
static const String getOrderStats = '$baseUrl/order/get';
|
||||
static String getOrderStatus(String id) => '$baseUrl/order/status/$id';
|
||||
|
||||
// ========== Reservation Endpoints ==========
|
||||
/// Endpoint to get reservations.
|
||||
/// Method: GET
|
||||
/// Headers: {'Authorization': 'Bearer <token>'}
|
||||
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 backDelete = Color.fromARGB(255, 254, 237,235);
|
||||
static const Color backEdit = Color.fromARGB(255, 237, 247, 238);
|
||||
static const Color analyticsGrey = Color.fromARGB(255, 246, 246, 246);
|
||||
}
|
||||
|
|
@ -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/http_overrides.dart';
|
||||
import 'package:business_panel/presentation/auth/bloc/auth_bloc.dart';
|
||||
import 'package:business_panel/presentation/home/bloc/home_bloc.dart';
|
||||
import 'package:business_panel/presentation/order/bloc/order_bloc.dart';
|
||||
import 'package:business_panel/presentation/pages/splash_page.dart';
|
||||
import 'package:business_panel/presentation/reservation/bloc/reservation_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
|
|
@ -17,6 +21,7 @@ void main() async {
|
|||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
HttpOverrides.global = MyHttpOverrides();
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
|
@ -33,6 +38,9 @@ class MyApp extends StatelessWidget {
|
|||
create: (context) => HomeBloc()..add(FetchDiscounts()),
|
||||
),
|
||||
BlocProvider(create: (context) => OrderBloc()),
|
||||
BlocProvider(
|
||||
create: (context) => ReservationBloc()..add(FetchReservations()),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Proxibuy',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// lib/presentation/pages/discount_manegment_page.dart
|
||||
import 'dart:async';
|
||||
import 'package:business_panel/core/config/app_colors.dart';
|
||||
import 'package:business_panel/domain/entities/discount_entity.dart';
|
||||
|
|
@ -32,8 +31,7 @@ class _DiscountManegmentView extends StatefulWidget {
|
|||
class _DiscountManegmentPageState extends State<_DiscountManegmentView> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
int _selectedStatus = 1; // 1 for active, 0 for inactive
|
||||
|
||||
int _selectedStatus = 1;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -216,7 +214,6 @@ class _DiscountManegmentPageState extends State<_DiscountManegmentView> {
|
|||
return const Center(
|
||||
child: Text("هیچ تخفیفی با این مشخصات یافت نشد."));
|
||||
}
|
||||
// Group discounts
|
||||
final Map<String, List<DiscountEntity>> groupedDiscounts = {};
|
||||
for (var discount in state.discounts) {
|
||||
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/presentation/discount_management/bloc/discount_management_bloc.dart';
|
||||
import 'package:business_panel/presentation/pages/add_discount_page.dart';
|
||||
import 'package:business_panel/presentation/pages/sales_analysis_page.dart';
|
||||
import 'package:business_panel/presentation/widgets/delete_confirmation_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
|
@ -50,9 +51,6 @@ class AnalyticsDiscountCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -103,11 +101,20 @@ class AnalyticsDiscountCard extends StatelessWidget {
|
|||
else
|
||||
_buildCountdownSection(remaining),
|
||||
const SizedBox(height: 10),
|
||||
_buildInfoRow(
|
||||
icon: Assets.icons.chart,
|
||||
text: "آنالیز فروش",
|
||||
textColor: AppColors.active,
|
||||
isBold: true,
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SalesAnalysisPage(discount: discount),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildInfoRow(
|
||||
icon: Assets.icons.chart,
|
||||
text: "آنالیز فروش",
|
||||
textColor: AppColors.active,
|
||||
isBold: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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/gen/assets.gen.dart';
|
||||
import 'package:business_panel/presentation/home/bloc/home_bloc.dart';
|
||||
import 'package:business_panel/presentation/pages/discount_manegment_page.dart';
|
||||
import 'package:business_panel/presentation/pages/reserve_manegment_page.dart';
|
||||
import 'package:business_panel/presentation/reservation/bloc/reservation_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
|
@ -27,7 +27,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
|
@ -38,13 +38,29 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||
builder: (context, state) {
|
||||
final count =
|
||||
state is HomeLoaded ? state.discounts.length : 0;
|
||||
return Row(
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const DiscountManegmentPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: SvgPicture.asset(
|
||||
Assets.icons.discountShape,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
if (count > 0)
|
||||
Positioned(
|
||||
top: 2,
|
||||
right: 6,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
|
|
@ -52,106 +68,90 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||
),
|
||||
);
|
||||
},
|
||||
icon: SvgPicture.asset(
|
||||
Assets.icons.discountShape,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
if (count > 0)
|
||||
Positioned(
|
||||
top: 2,
|
||||
right: 6,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const DiscountManegmentPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
BlocBuilder<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(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (count > 0)
|
||||
Positioned(
|
||||
top: 2,
|
||||
right: 6,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const ReserveManegmment(),
|
||||
builder: (_) => const ReserveManegmment(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon:
|
||||
SvgPicture.asset(Assets.icons.scanBarcode),
|
||||
),
|
||||
if (count > 0)
|
||||
Positioned(
|
||||
top: 2,
|
||||
right: 6,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const ReserveManegmment(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.selectedImg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.selectedImg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ dependencies:
|
|||
vibration: ^3.1.3
|
||||
audioplayers: ^6.5.0
|
||||
jwt_decoder: ^2.0.1
|
||||
fl_chart: ^1.0.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
Loading…
Reference in New Issue