679 lines
22 KiB
Dart
679 lines
22 KiB
Dart
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:siro_admin/constant/theme.dart';
|
|
import 'package:siro_admin/controller/admin/analytics_v2_controller.dart';
|
|
|
|
class AdvancedAnalyticsPage extends StatelessWidget {
|
|
const AdvancedAnalyticsPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
backgroundColor: cs.surface,
|
|
body: Column(
|
|
children: [
|
|
_buildAppBar(context, cs),
|
|
Expanded(
|
|
child: GetBuilder<AnalyticsV2Controller>(
|
|
init: Get.isRegistered<AnalyticsV2Controller>()
|
|
? Get.find<AnalyticsV2Controller>()
|
|
: AnalyticsV2Controller(),
|
|
builder: (ctrl) {
|
|
if (ctrl.isLoading) {
|
|
return _buildLoadingState(cs);
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSummarySection(ctrl.revenueData['summary'], cs),
|
|
const SizedBox(height: 24),
|
|
_buildSectionTitle('إيرادات آخر 30 يوم', cs),
|
|
_buildRevenueChart(ctrl.revenueData['daily'] ?? [], cs),
|
|
const SizedBox(height: 32),
|
|
_buildSectionTitle('نمو المستخدمين (آخر 30 يوم)', cs),
|
|
_buildGrowthChart(ctrl.growthData, cs),
|
|
const SizedBox(height: 32),
|
|
_buildSectionTitle('أفضل 10 سائقين (حسب الرحلات)', cs),
|
|
_buildTopDriversList(ctrl.topDrivers, cs),
|
|
const SizedBox(height: 40),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBar(BuildContext context, ColorScheme cs) {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 50, 16, 16),
|
|
decoration: BoxDecoration(
|
|
color: cs.surface,
|
|
border: Border(bottom: BorderSide(color: cs.outline)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () => Get.back(),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: cs.outline),
|
|
),
|
|
child: Icon(Icons.arrow_back_ios_new_rounded,
|
|
color: cs.onSurfaceVariant, size: 16),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: cs.primary.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: cs.primary.withValues(alpha: 0.25)),
|
|
),
|
|
child: Icon(Icons.analytics_rounded,
|
|
color: cs.primary, size: 18),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
'التحليلات المتقدمة',
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
GestureDetector(
|
|
onTap: () => Get.find<AnalyticsV2Controller>().fetchAllAnalytics(),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: cs.outline),
|
|
),
|
|
child: Icon(Icons.refresh_rounded,
|
|
color: cs.onSurfaceVariant, size: 18),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLoadingState(ColorScheme cs) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: CircularProgressIndicator(
|
|
color: cs.primary,
|
|
strokeWidth: 2,
|
|
backgroundColor: cs.primary.withValues(alpha: 0.1),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text('جاري تحميل التحليلات...',
|
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionTitle(String title, ColorScheme cs) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 3,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: cs.primary,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSummarySection(
|
|
Map<String, dynamic>? summary, ColorScheme cs) {
|
|
if (summary == null) return const SizedBox();
|
|
|
|
return Row(
|
|
children: [
|
|
_buildSummaryCard(
|
|
'إجمالي الإيرادات',
|
|
'${summary['total_revenue_all'] ?? 0}',
|
|
cs.tertiary,
|
|
Icons.attach_money_rounded,
|
|
cs,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_buildSummaryCard(
|
|
'صافي الربح',
|
|
'${summary['total_profit_all'] ?? 0}',
|
|
cs.success,
|
|
Icons.trending_up_rounded,
|
|
cs,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSummaryCard(
|
|
String title, String value, Color color, IconData icon, ColorScheme cs) {
|
|
return Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: color.withValues(alpha: 0.2)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withValues(alpha: 0.06),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: color.withValues(alpha: 0.25)),
|
|
),
|
|
child: Icon(icon, color: color, size: 18),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant, fontSize: 12),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w800,
|
|
height: 1,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRevenueChart(List<dynamic> daily, ColorScheme cs) {
|
|
if (daily.isEmpty) {
|
|
return Container(
|
|
height: 200,
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: cs.outline),
|
|
),
|
|
child: Center(
|
|
child: Text('لا توجد بيانات',
|
|
style: TextStyle(color: cs.onSurfaceVariant)),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<FlSpot> revenueSpots = [];
|
|
List<FlSpot> profitSpots = [];
|
|
|
|
for (int i = 0; i < daily.length; i++) {
|
|
revenueSpots.add(FlSpot(i.toDouble(),
|
|
double.tryParse(daily[i]['total_revenue'].toString()) ?? 0));
|
|
profitSpots.add(FlSpot(i.toDouble(),
|
|
double.tryParse(daily[i]['company_profit'].toString()) ?? 0));
|
|
}
|
|
|
|
return Container(
|
|
height: 300,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: cs.outline),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: cs.shadow.withValues(alpha: 0.06),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_LegendDot(color: cs.tertiary, label: 'الإيرادات'),
|
|
const SizedBox(width: 16),
|
|
_LegendDot(color: cs.success, label: 'الربح'),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: LineChart(
|
|
LineChartData(
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
getDrawingHorizontalLine: (v) => FlLine(
|
|
color: cs.outline.withValues(alpha: 0.5),
|
|
strokeWidth: 1),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
show: true,
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 40,
|
|
getTitlesWidget: (v, _) => Text(
|
|
v >= 1000 ? '${(v / 1000).toStringAsFixed(0)}k' : v.toInt().toString(),
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant, fontSize: 9),
|
|
),
|
|
),
|
|
),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 24,
|
|
getTitlesWidget: (val, meta) {
|
|
if (val.toInt() % 7 == 0 &&
|
|
val.toInt() < daily.length) {
|
|
return Text(
|
|
daily[val.toInt()]['date']
|
|
.toString()
|
|
.substring(8),
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 9),
|
|
);
|
|
}
|
|
return const SizedBox();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: revenueSpots,
|
|
isCurved: true,
|
|
color: cs.tertiary,
|
|
barWidth: 3,
|
|
isStrokeCapRound: true,
|
|
dotData: const FlDotData(show: false),
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: cs.tertiary.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
LineChartBarData(
|
|
spots: profitSpots,
|
|
isCurved: true,
|
|
color: cs.success,
|
|
barWidth: 3,
|
|
isStrokeCapRound: true,
|
|
dotData: const FlDotData(show: false),
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: cs.success.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGrowthChart(Map<String, dynamic> data, ColorScheme cs) {
|
|
final passengers = data['passenger_daily'] as List<dynamic>? ?? [];
|
|
final drivers = data['driver_daily'] as List<dynamic>? ?? [];
|
|
|
|
if (passengers.isEmpty && drivers.isEmpty) {
|
|
return Container(
|
|
height: 200,
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: cs.outline),
|
|
),
|
|
child: Center(
|
|
child: Text('لا توجد بيانات',
|
|
style: TextStyle(color: cs.onSurfaceVariant)),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<BarChartGroupData> barGroups = [];
|
|
int maxLength =
|
|
passengers.length > drivers.length ? passengers.length : drivers.length;
|
|
|
|
for (int i = 0; i < maxLength; i++) {
|
|
double pCount = i < passengers.length
|
|
? double.tryParse(passengers[i]['new_passengers'].toString()) ?? 0
|
|
: 0;
|
|
double dCount = i < drivers.length
|
|
? double.tryParse(drivers[i]['new_drivers'].toString()) ?? 0
|
|
: 0;
|
|
|
|
barGroups.add(
|
|
BarChartGroupData(
|
|
x: i,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: pCount,
|
|
color: cs.tertiary.withValues(alpha: 0.7),
|
|
width: 8,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
BarChartRodData(
|
|
toY: dCount,
|
|
color: cs.warning.withValues(alpha: 0.7),
|
|
width: 8,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
height: 280,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: cs.outline),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: cs.shadow.withValues(alpha: 0.06),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_LegendDot(color: cs.tertiary, label: 'ركاب جدد'),
|
|
const SizedBox(width: 16),
|
|
_LegendDot(
|
|
color: cs.warning, label: 'سائقين جدد'),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: BarChart(
|
|
BarChartData(
|
|
barGroups: barGroups,
|
|
borderData: FlBorderData(show: false),
|
|
titlesData: FlTitlesData(
|
|
show: true,
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 40,
|
|
getTitlesWidget: (v, _) => Text(
|
|
v.toInt().toString(),
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant, fontSize: 9),
|
|
),
|
|
),
|
|
),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 24,
|
|
getTitlesWidget: (val, meta) {
|
|
if (val.toInt() % 7 == 0 &&
|
|
val.toInt() < passengers.length) {
|
|
return Text(
|
|
passengers[val.toInt()]['date']
|
|
.toString()
|
|
.substring(8),
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 9),
|
|
);
|
|
}
|
|
return const SizedBox();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTopDriversList(List<dynamic> drivers, ColorScheme cs) {
|
|
if (drivers.isEmpty) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: cs.outline),
|
|
),
|
|
child: Center(
|
|
child: Text('لا توجد بيانات',
|
|
style: TextStyle(color: cs.onSurfaceVariant)),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: drivers.length,
|
|
itemBuilder: (ctx, i) {
|
|
final d = drivers[i];
|
|
final isTop3 = i < 3;
|
|
final rankColors = [
|
|
cs.warning,
|
|
cs.onSurfaceVariant,
|
|
const Color(0xFFCD7F32),
|
|
];
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 10),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: cs.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isTop3
|
|
? rankColors[i].withValues(alpha: 0.2)
|
|
: cs.outline,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: isTop3
|
|
? rankColors[i].withValues(alpha: 0.12)
|
|
: cs.outline,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: isTop3
|
|
? rankColors[i].withValues(alpha: 0.3)
|
|
: Colors.transparent,
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${i + 1}',
|
|
style: TextStyle(
|
|
color: isTop3 ? rankColors[i] : cs.onSurfaceVariant,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${d['first_name']} ${d['last_name']}',
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
d['phone'] ?? '',
|
|
style: TextStyle(
|
|
color: cs.onSurfaceVariant,
|
|
fontSize: 11,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: cs.tertiary.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
'${d['completed_rides']} رحلة',
|
|
style: TextStyle(
|
|
color: cs.tertiary,
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${d['total_revenue']} ل.س',
|
|
style: TextStyle(
|
|
color: cs.success,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LegendDot extends StatelessWidget {
|
|
final Color color;
|
|
final String label;
|
|
|
|
const _LegendDot({required this.color, required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|