1337 lines
46 KiB
Dart
1337 lines
46 KiB
Dart
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
import '../../../constant/colors.dart';
|
|
import '../../../controller/admin/live_analytics_controller.dart';
|
|
|
|
class LiveAnalyticsPage extends StatelessWidget {
|
|
const LiveAnalyticsPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = Get.put(LiveAnalyticsController());
|
|
|
|
return DefaultTabController(
|
|
length: 4,
|
|
child: Scaffold(
|
|
backgroundColor: AppColor.bg,
|
|
appBar: AppBar(
|
|
title: const Text('لوحة البيانات التفاعلية',
|
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
|
backgroundColor: AppColor.surface,
|
|
elevation: 0,
|
|
centerTitle: true,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.calendar_month_rounded, size: 22),
|
|
onPressed: () => _pickDate(context, c),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh_rounded, size: 22),
|
|
onPressed: () => c.fetchAll(),
|
|
),
|
|
],
|
|
bottom: const TabBar(
|
|
isScrollable: true,
|
|
indicatorColor: AppColor.accent,
|
|
labelColor: AppColor.accent,
|
|
unselectedLabelColor: AppColor.textSecondary,
|
|
labelStyle: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
|
tabs: [
|
|
Tab(text: 'الخريطة التفاعلية', icon: Icon(Icons.map_rounded, size: 18)),
|
|
Tab(text: 'السوق والتسعير', icon: Icon(Icons.trending_up_rounded, size: 18)),
|
|
Tab(text: 'الأسطول والجودة', icon: Icon(Icons.local_taxi_rounded, size: 18)),
|
|
Tab(text: 'النمو والاحتفاظ', icon: Icon(Icons.show_chart_rounded, size: 18)),
|
|
],
|
|
),
|
|
),
|
|
body: GetBuilder<LiveAnalyticsController>(
|
|
builder: (ctrl) {
|
|
if (ctrl.isLoading) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(color: AppColor.accent),
|
|
);
|
|
}
|
|
if (ctrl.allData.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.cloud_off_rounded,
|
|
size: 48, color: AppColor.textMuted),
|
|
const SizedBox(height: 12),
|
|
Text('لا توجد بيانات لتاريخ ${ctrl.selectedDate}',
|
|
style: const TextStyle(color: AppColor.textSecondary)),
|
|
const SizedBox(height: 8),
|
|
if (ctrl.availableDates.isNotEmpty)
|
|
Text('آخر تاريخ متاح: ${ctrl.availableDates.first}',
|
|
style: const TextStyle(
|
|
color: AppColor.accent, fontSize: 13)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return TabBarView(
|
|
children: [
|
|
_MapTab(ctrl: ctrl),
|
|
_MarketTab(ctrl: ctrl),
|
|
_FleetTab(ctrl: ctrl),
|
|
_GrowthTab(ctrl: ctrl),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _pickDate(BuildContext context, LiveAnalyticsController c) async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: DateTime.tryParse(c.selectedDate) ?? DateTime.now(),
|
|
firstDate: DateTime(2024),
|
|
lastDate: DateTime.now(),
|
|
builder: (ctx, child) => Theme(
|
|
data: ThemeData.dark().copyWith(
|
|
colorScheme: const ColorScheme.dark(primary: AppColor.accent),
|
|
),
|
|
child: child!,
|
|
),
|
|
);
|
|
if (picked != null) {
|
|
c.changeDate(
|
|
'${picked.year}-${picked.month.toString().padLeft(2, '0')}-${picked.day.toString().padLeft(2, '0')}');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Shared Widgets
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
class _StatCard extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final String? sub;
|
|
final Color? subColor;
|
|
final IconData? icon;
|
|
|
|
const _StatCard({
|
|
required this.label,
|
|
required this.value,
|
|
this.sub,
|
|
this.subColor,
|
|
this.icon,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColor.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColor.divider),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (icon != null) ...[
|
|
Icon(icon, size: 16, color: AppColor.textMuted),
|
|
const SizedBox(width: 6),
|
|
],
|
|
Expanded(
|
|
child: Text(label,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColor.textMuted,
|
|
fontWeight: FontWeight.w600)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(value,
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColor.textPrimary)),
|
|
if (sub != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(sub!,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: subColor ?? AppColor.textSecondary)),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionTitle extends StatelessWidget {
|
|
final String title;
|
|
const _SectionTitle(this.title);
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12, top: 8),
|
|
child: Text(title,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColor.textPrimary)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChartCard extends StatelessWidget {
|
|
final String title;
|
|
final Widget child;
|
|
final double height;
|
|
|
|
const _ChartCard({
|
|
required this.title,
|
|
required this.child,
|
|
this.height = 220,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColor.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColor.divider),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColor.textSecondary)),
|
|
const SizedBox(height: 16),
|
|
SizedBox(height: height, child: child),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _fmt(dynamic n) {
|
|
if (n == null) return '—';
|
|
if (n is num) {
|
|
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}k';
|
|
return n % 1 == 0 ? n.toInt().toString() : n.toStringAsFixed(1);
|
|
}
|
|
return n.toString();
|
|
}
|
|
|
|
double _toDouble(dynamic v) {
|
|
if (v is num) return v.toDouble();
|
|
if (v is String) return double.tryParse(v) ?? 0;
|
|
return 0;
|
|
}
|
|
|
|
int _toInt(dynamic v) {
|
|
if (v is int) return v;
|
|
if (v is num) return v.toInt();
|
|
if (v is String) return int.tryParse(v) ?? 0;
|
|
return 0;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// TAB 1: الخريطة التفاعلية
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
class _MapTab extends StatefulWidget {
|
|
final LiveAnalyticsController ctrl;
|
|
const _MapTab({required this.ctrl});
|
|
@override
|
|
State<_MapTab> createState() => _MapTabState();
|
|
}
|
|
|
|
class _MapTabState extends State<_MapTab> with AutomaticKeepAliveClientMixin {
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
bool showGap = true;
|
|
bool showHeatmap = true;
|
|
bool showSupply = false;
|
|
bool showPricing = false;
|
|
bool showZones = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
final c = widget.ctrl;
|
|
final rt = c.realtime;
|
|
|
|
final revToday = _toDouble(rt['revenue_today']);
|
|
final revYesterday = _toDouble(rt['revenue_yesterday']);
|
|
final revChange = revYesterday > 0
|
|
? ((revToday - revYesterday) / revYesterday * 100)
|
|
: 0.0;
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// KPI Cards
|
|
GridView.count(
|
|
crossAxisCount: MediaQuery.of(context).size.width > 600 ? 5 : 2,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 1.6,
|
|
children: [
|
|
_StatCard(
|
|
label: 'رحلات نشطة',
|
|
value: _fmt(rt['active_rides']),
|
|
icon: Icons.directions_car_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'كباتن أونلاين',
|
|
value: _fmt(rt['online_drivers']),
|
|
icon: Icons.person_pin_circle_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'إيرادات اليوم',
|
|
value: _fmt(revToday),
|
|
sub: '${revChange >= 0 ? '+' : ''}${revChange.toStringAsFixed(1)}% عن الأمس',
|
|
subColor: revChange >= 0 ? AppColor.success : AppColor.danger,
|
|
icon: Icons.attach_money_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'شكاوى مفتوحة',
|
|
value: _fmt(rt['open_complaints']),
|
|
icon: Icons.warning_amber_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'رخص تنتهي قريباً',
|
|
value: _fmt(rt['expiring_licenses']),
|
|
icon: Icons.badge_rounded,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
const _SectionTitle('خريطة فجوة العرض والطلب'),
|
|
_buildLayerToggles(),
|
|
const SizedBox(height: 8),
|
|
_buildMap(c),
|
|
|
|
const SizedBox(height: 20),
|
|
_buildHourlyChart(c),
|
|
_buildFunnelChart(c),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildLayerToggles() {
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
_LayerChip('فجوة العرض/الطلب', showGap, Colors.redAccent,
|
|
(v) => setState(() => showGap = v)),
|
|
_LayerChip('كثافة الطلب', showHeatmap, Colors.orange,
|
|
(v) => setState(() => showHeatmap = v)),
|
|
_LayerChip('مواقع الكباتن', showSupply, AppColor.success,
|
|
(v) => setState(() => showSupply = v)),
|
|
_LayerChip('أسعار المنافسين', showPricing, AppColor.info,
|
|
(v) => setState(() => showPricing = v)),
|
|
_LayerChip('أعلى المناطق', showZones, AppColor.accent,
|
|
(v) => setState(() => showZones = v)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMap(LiveAnalyticsController c) {
|
|
final gapCells = (c.gap['cells'] as List?) ?? [];
|
|
final heatmapData = c.heatmap['data'];
|
|
final pricingGrids = c.pricing['grids'];
|
|
final topZones = (c.zones['zones'] as List?) ?? [];
|
|
|
|
List<CircleMarker> markers = [];
|
|
|
|
if (showGap) {
|
|
for (var cell in gapCells) {
|
|
final lat = _toDouble(cell['lat']);
|
|
final lng = _toDouble(cell['lng']);
|
|
final gap = _toInt(cell['gap']);
|
|
if (lat == 0 || lng == 0) continue;
|
|
final intensity = (gap.abs() / 5).clamp(0.0, 1.0);
|
|
markers.add(CircleMarker(
|
|
point: LatLng(lat, lng),
|
|
color: gap > 0
|
|
? Colors.redAccent.withOpacity(0.3 + intensity * 0.5)
|
|
: AppColor.success.withOpacity(0.4),
|
|
borderStrokeWidth: 0,
|
|
radius: 12 + intensity * 8,
|
|
));
|
|
}
|
|
}
|
|
|
|
if (showHeatmap && heatmapData is Map) {
|
|
for (var entry in heatmapData.entries) {
|
|
if (entry.value is! List) continue;
|
|
for (var pt in (entry.value as List).take(500)) {
|
|
final lat = _toDouble(pt['lat']);
|
|
final lng = _toDouble(pt['lng']);
|
|
if (lat == 0 || lng == 0) continue;
|
|
markers.add(CircleMarker(
|
|
point: LatLng(lat, lng),
|
|
color: Colors.orange.withOpacity(0.35),
|
|
borderStrokeWidth: 0,
|
|
radius: 5,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showSupply) {
|
|
for (var cell in gapCells) {
|
|
final supply = _toInt(cell['supply']);
|
|
if (supply <= 0) continue;
|
|
final lat = _toDouble(cell['lat']);
|
|
final lng = _toDouble(cell['lng']);
|
|
if (lat == 0 || lng == 0) continue;
|
|
markers.add(CircleMarker(
|
|
point: LatLng(lat, lng),
|
|
color: AppColor.success.withOpacity(0.5),
|
|
borderStrokeWidth: 1,
|
|
borderColor: Colors.white.withOpacity(0.4),
|
|
radius: 6 + supply.clamp(0, 10).toDouble(),
|
|
));
|
|
}
|
|
}
|
|
|
|
if (showPricing && pricingGrids is Map) {
|
|
for (var entry in pricingGrids.entries) {
|
|
final key = entry.key.toString();
|
|
if (key.contains('FALLBACK')) continue;
|
|
final parts = key.split('_');
|
|
if (parts.length < 3) continue;
|
|
final lat = double.tryParse(parts[1]) ?? 0;
|
|
final lng = double.tryParse(parts[2]) ?? 0;
|
|
if (lat == 0 || lng == 0) continue;
|
|
markers.add(CircleMarker(
|
|
point: LatLng(lat, lng),
|
|
color: AppColor.info.withOpacity(0.5),
|
|
borderStrokeWidth: 1,
|
|
borderColor: Colors.white.withOpacity(0.3),
|
|
radius: 8,
|
|
));
|
|
}
|
|
}
|
|
|
|
if (showZones) {
|
|
for (var z in topZones) {
|
|
final lat = _toDouble(z['lat']);
|
|
final lng = _toDouble(z['lng']);
|
|
final rides = _toInt(z['rides']);
|
|
if (lat == 0 || lng == 0) continue;
|
|
markers.add(CircleMarker(
|
|
point: LatLng(lat, lng),
|
|
color: AppColor.accent.withOpacity(0.5),
|
|
borderStrokeWidth: 2,
|
|
borderColor: Colors.white.withOpacity(0.5),
|
|
radius: 10 + (rides / 10).clamp(0, 15).toDouble(),
|
|
));
|
|
}
|
|
}
|
|
|
|
LatLng center = const LatLng(31.95, 35.93);
|
|
if (gapCells.isNotEmpty) {
|
|
center = LatLng(
|
|
_toDouble(gapCells.first['lat']), _toDouble(gapCells.first['lng']));
|
|
}
|
|
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: SizedBox(
|
|
height: 400,
|
|
child: FlutterMap(
|
|
options: MapOptions(initialCenter: center, initialZoom: 11),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate:
|
|
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
|
subdomains: const ['a', 'b', 'c', 'd'],
|
|
),
|
|
CircleLayer(circles: markers),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHourlyChart(LiveAnalyticsController c) {
|
|
final hours = (c.hourly['hours'] as List?) ?? [];
|
|
if (hours.isEmpty) return const SizedBox.shrink();
|
|
|
|
return _ChartCard(
|
|
title: 'توزيع الرحلات حسب الساعة (آخر 7 أيام)',
|
|
child: BarChart(
|
|
BarChartData(
|
|
barGroups: hours.asMap().entries.map((e) {
|
|
return BarChartGroupData(x: e.key, barRods: [
|
|
BarChartRodData(
|
|
toY: _toDouble(e.value['rides']),
|
|
color: AppColor.accent.withOpacity(0.7),
|
|
width: 10,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
|
),
|
|
]);
|
|
}).toList(),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= hours.length) return const SizedBox();
|
|
return Text('${hours[i]['hour']}',
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFunnelChart(LiveAnalyticsController c) {
|
|
final statuses = (c.funnel['statuses'] as List?) ?? [];
|
|
if (statuses.isEmpty) return const SizedBox.shrink();
|
|
|
|
final colors = {
|
|
'Finished': AppColor.success,
|
|
'wait': AppColor.warning,
|
|
'started': AppColor.info,
|
|
'arrived': AppColor.accent,
|
|
'cancelled': AppColor.danger,
|
|
'timeout': AppColor.textMuted,
|
|
};
|
|
|
|
return _ChartCard(
|
|
title: 'قمع الرحلات (حالات الطلبات)',
|
|
child: PieChart(
|
|
PieChartData(
|
|
sections: statuses.map((s) {
|
|
final status = s['status']?.toString() ?? '';
|
|
final count = _toDouble(s['count']);
|
|
return PieChartSectionData(
|
|
value: count,
|
|
title: '$status\n${count.toInt()}',
|
|
titleStyle: const TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white),
|
|
color: colors[status] ?? AppColor.textMuted,
|
|
radius: 60,
|
|
);
|
|
}).toList(),
|
|
sectionsSpace: 2,
|
|
centerSpaceRadius: 30,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LayerChip extends StatelessWidget {
|
|
final String label;
|
|
final bool active;
|
|
final Color color;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
const _LayerChip(this.label, this.active, this.color, this.onChanged);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FilterChip(
|
|
label: Text(label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: active ? Colors.white : AppColor.textSecondary)),
|
|
selected: active,
|
|
onSelected: onChanged,
|
|
selectedColor: color.withOpacity(0.3),
|
|
checkmarkColor: color,
|
|
backgroundColor: AppColor.surface,
|
|
side: BorderSide(
|
|
color: active ? color.withOpacity(0.5) : AppColor.divider),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// TAB 2: السوق والتسعير
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
class _MarketTab extends StatelessWidget {
|
|
final LiveAnalyticsController ctrl;
|
|
const _MarketTab({required this.ctrl});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final mkt = ctrl.market;
|
|
final countries = mkt['countries'] as Map<String, dynamic>? ?? {};
|
|
String latestPci = '—';
|
|
String latestShare = '—';
|
|
|
|
if (countries.isNotEmpty) {
|
|
final firstRows = countries.values.first as List? ?? [];
|
|
if (firstRows.isNotEmpty) {
|
|
final last = firstRows.last;
|
|
latestPci = _toDouble(last['average_pci']).toStringAsFixed(2);
|
|
latestShare =
|
|
'${_toDouble(last['market_share_percent']).toStringAsFixed(1)}%';
|
|
}
|
|
}
|
|
|
|
final pricingGrids = ctrl.pricing['grids'] as Map? ?? {};
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
GridView.count(
|
|
crossAxisCount: MediaQuery.of(context).size.width > 600 ? 3 : 2,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 1.8,
|
|
children: [
|
|
_StatCard(
|
|
label: 'مؤشر PCI الحالي',
|
|
value: latestPci,
|
|
sub: 'الهدف: 0.90 - 0.95',
|
|
icon: Icons.price_change_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'الحصة السوقية',
|
|
value: latestShare,
|
|
icon: Icons.pie_chart_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'مربعات تسعيرية',
|
|
value: pricingGrids.length.toString(),
|
|
icon: Icons.grid_on_rounded,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
_buildCompetitorChart(),
|
|
_buildPciChart(countries),
|
|
_buildMarketShareChart(countries),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildCompetitorChart() {
|
|
final hourly = (ctrl.competitor['hourly'] as List?) ?? [];
|
|
if (hourly.isEmpty) return const SizedBox.shrink();
|
|
|
|
final competitors = <String>{};
|
|
for (var r in hourly) {
|
|
competitors.add(r['competitor_name']?.toString() ?? '');
|
|
}
|
|
|
|
final compColors = [
|
|
AppColor.danger,
|
|
AppColor.info,
|
|
AppColor.warning,
|
|
AppColor.success,
|
|
AppColor.accent,
|
|
];
|
|
final hours = <String>{};
|
|
for (var r in hourly) {
|
|
hours.add(r['hour_bucket']?.toString().substring(11, 16) ?? '');
|
|
}
|
|
final hourList = hours.toList();
|
|
|
|
return _ChartCard(
|
|
title: 'أسعار المنافسين (آخر 24 ساعة)',
|
|
height: 250,
|
|
child: LineChart(
|
|
LineChartData(
|
|
lineBarsData: competitors.toList().asMap().entries.map((e) {
|
|
final name = e.value;
|
|
final spots = <FlSpot>[];
|
|
for (var i = 0; i < hourList.length; i++) {
|
|
final row = hourly.firstWhere(
|
|
(r) =>
|
|
r['competitor_name'] == name &&
|
|
(r['hour_bucket']?.toString().substring(11, 16) ?? '') ==
|
|
hourList[i],
|
|
orElse: () => null,
|
|
);
|
|
if (row != null) {
|
|
spots.add(FlSpot(i.toDouble(), _toDouble(row['avg_price'])));
|
|
}
|
|
}
|
|
return LineChartBarData(
|
|
spots: spots,
|
|
color: compColors[e.key % compColors.length],
|
|
barWidth: 2,
|
|
dotData: const FlDotData(show: false),
|
|
isCurved: true,
|
|
curveSmoothness: 0.3,
|
|
);
|
|
}).toList(),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: (hourList.length / 6).ceilToDouble(),
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= hourList.length) return const SizedBox();
|
|
return Text(hourList[i],
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
getDrawingHorizontalLine: (_) =>
|
|
FlLine(color: AppColor.divider, strokeWidth: 0.5),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPciChart(Map<String, dynamic> countries) {
|
|
if (countries.isEmpty) return const SizedBox.shrink();
|
|
final rows = (countries.values.first as List?) ?? [];
|
|
if (rows.isEmpty) return const SizedBox.shrink();
|
|
|
|
return _ChartCard(
|
|
title: 'مؤشر تنافسية الأسعار (PCI) - تاريخي',
|
|
child: LineChart(
|
|
LineChartData(
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: rows.asMap().entries.map((e) {
|
|
return FlSpot(
|
|
e.key.toDouble(), _toDouble(e.value['average_pci']));
|
|
}).toList(),
|
|
color: AppColor.accent,
|
|
barWidth: 2,
|
|
dotData: const FlDotData(show: true),
|
|
isCurved: true,
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: AppColor.accent.withOpacity(0.08),
|
|
),
|
|
),
|
|
],
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= rows.length) return const SizedBox();
|
|
final d = rows[i]['report_date']?.toString() ?? '';
|
|
return Text(d.length >= 10 ? d.substring(5) : d,
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMarketShareChart(Map<String, dynamic> countries) {
|
|
if (countries.isEmpty) return const SizedBox.shrink();
|
|
final rows = (countries.values.first as List?) ?? [];
|
|
if (rows.isEmpty) return const SizedBox.shrink();
|
|
|
|
return _ChartCard(
|
|
title: 'الحصة السوقية الأسبوعية',
|
|
child: BarChart(
|
|
BarChartData(
|
|
barGroups: rows.asMap().entries.map((e) {
|
|
return BarChartGroupData(x: e.key, barRods: [
|
|
BarChartRodData(
|
|
toY: _toDouble(e.value['market_share_percent']),
|
|
color: const Color(0xFF00CEC9).withOpacity(0.7),
|
|
width: 14,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(4)),
|
|
),
|
|
]);
|
|
}).toList(),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= rows.length) return const SizedBox();
|
|
final d = rows[i]['report_date']?.toString() ?? '';
|
|
return Text(d.length >= 10 ? d.substring(5) : d,
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// TAB 3: الأسطول والجودة
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
class _FleetTab extends StatelessWidget {
|
|
final LiveAnalyticsController ctrl;
|
|
const _FleetTab({required this.ctrl});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final rt = ctrl.realtime;
|
|
final complaintsList = (ctrl.complaints['by_type'] as List?) ?? [];
|
|
final totalComplaints =
|
|
complaintsList.fold<int>(0, (s, c) => s + _toInt(c['count']));
|
|
final topZones = (ctrl.zones['zones'] as List?) ?? [];
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
GridView.count(
|
|
crossAxisCount: MediaQuery.of(context).size.width > 600 ? 3 : 2,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 1.8,
|
|
children: [
|
|
_StatCard(
|
|
label: 'كباتن أونلاين',
|
|
value: _fmt(rt['online_drivers']),
|
|
icon: Icons.person_pin_circle_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'شكاوى مفتوحة',
|
|
value: totalComplaints.toString(),
|
|
icon: Icons.warning_amber_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'رخص تنتهي قريباً',
|
|
value: _fmt(rt['expiring_licenses']),
|
|
icon: Icons.badge_rounded,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
_buildComplaintsChart(complaintsList),
|
|
_buildRevenueProfitChart(),
|
|
|
|
if (topZones.isNotEmpty) ...[
|
|
const _SectionTitle('أعلى المناطق نشاطاً (آخر 4 أسابيع)'),
|
|
_buildZonesTable(topZones),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildComplaintsChart(List complaintsList) {
|
|
if (complaintsList.isEmpty) return const SizedBox.shrink();
|
|
|
|
final colors = [
|
|
AppColor.danger,
|
|
AppColor.warning,
|
|
AppColor.info,
|
|
AppColor.accent,
|
|
AppColor.success,
|
|
AppColor.textMuted,
|
|
const Color(0xFFfd79a8),
|
|
];
|
|
|
|
return _ChartCard(
|
|
title: 'توزيع الشكاوى حسب النوع',
|
|
child: PieChart(
|
|
PieChartData(
|
|
sections: complaintsList.asMap().entries.map((e) {
|
|
return PieChartSectionData(
|
|
value: _toDouble(e.value['count']),
|
|
title:
|
|
'${e.value['complaint_type']}\n${_toInt(e.value['count'])}',
|
|
titleStyle: const TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white),
|
|
color: colors[e.key % colors.length],
|
|
radius: 65,
|
|
);
|
|
}).toList(),
|
|
sectionsSpace: 2,
|
|
centerSpaceRadius: 25,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRevenueProfitChart() {
|
|
final daily = (ctrl.revenue['daily'] as List?) ?? [];
|
|
if (daily.isEmpty) return const SizedBox.shrink();
|
|
|
|
return _ChartCard(
|
|
title: 'الإيرادات اليومية vs أرباح الشركة',
|
|
height: 250,
|
|
child: LineChart(
|
|
LineChartData(
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: daily.asMap().entries.map((e) {
|
|
return FlSpot(
|
|
e.key.toDouble(), _toDouble(e.value['total_revenue']));
|
|
}).toList(),
|
|
color: AppColor.info,
|
|
barWidth: 2,
|
|
dotData: const FlDotData(show: false),
|
|
isCurved: true,
|
|
),
|
|
LineChartBarData(
|
|
spots: daily.asMap().entries.map((e) {
|
|
return FlSpot(
|
|
e.key.toDouble(), _toDouble(e.value['company_profit']));
|
|
}).toList(),
|
|
color: AppColor.success,
|
|
barWidth: 2,
|
|
dotData: const FlDotData(show: false),
|
|
isCurved: true,
|
|
),
|
|
],
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: (daily.length / 6).ceilToDouble(),
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= daily.length) return const SizedBox();
|
|
final d = daily[i]['date']?.toString() ?? '';
|
|
return Text(d.length >= 10 ? d.substring(5) : d,
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
getDrawingHorizontalLine: (_) =>
|
|
FlLine(color: AppColor.divider, strokeWidth: 0.5),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildZonesTable(List topZones) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColor.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColor.divider),
|
|
),
|
|
child: DataTable(
|
|
columnSpacing: 20,
|
|
headingRowColor: WidgetStateProperty.all(AppColor.surfaceElevated),
|
|
columns: const [
|
|
DataColumn(
|
|
label: Text('#',
|
|
style:
|
|
TextStyle(fontSize: 12, color: AppColor.textSecondary))),
|
|
DataColumn(
|
|
label: Text('الموقع',
|
|
style:
|
|
TextStyle(fontSize: 12, color: AppColor.textSecondary))),
|
|
DataColumn(
|
|
label: Text('رحلات',
|
|
style:
|
|
TextStyle(fontSize: 12, color: AppColor.textSecondary))),
|
|
DataColumn(
|
|
label: Text('متوسط السعر',
|
|
style:
|
|
TextStyle(fontSize: 12, color: AppColor.textSecondary))),
|
|
],
|
|
rows: topZones.asMap().entries.map((e) {
|
|
final z = e.value;
|
|
return DataRow(cells: [
|
|
DataCell(Text('${e.key + 1}',
|
|
style: const TextStyle(
|
|
fontSize: 12, color: AppColor.textPrimary))),
|
|
DataCell(Text(
|
|
'${_toDouble(z['lat']).toStringAsFixed(4)}, ${_toDouble(z['lng']).toStringAsFixed(4)}',
|
|
style: const TextStyle(
|
|
fontSize: 12, color: AppColor.textPrimary))),
|
|
DataCell(Text(_fmt(z['rides']),
|
|
style: const TextStyle(
|
|
fontSize: 12, color: AppColor.textPrimary))),
|
|
DataCell(Text(_toDouble(z['avg_price']).toStringAsFixed(2),
|
|
style: const TextStyle(
|
|
fontSize: 12, color: AppColor.textPrimary))),
|
|
]);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// TAB 4: النمو والاحتفاظ
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
class _GrowthTab extends StatelessWidget {
|
|
final LiveAnalyticsController ctrl;
|
|
const _GrowthTab({required this.ctrl});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final gr = ctrl.growth;
|
|
final totals = gr['totals'] as Map<String, dynamic>? ?? {};
|
|
final weeks = (ctrl.weekly['weeks'] as List?) ?? [];
|
|
|
|
String weekChange = '';
|
|
Color weekColor = AppColor.textSecondary;
|
|
if (weeks.length >= 2) {
|
|
final last = _toInt(weeks.last['rides']);
|
|
final prev = _toInt(weeks[weeks.length - 2]['rides']);
|
|
if (prev > 0) {
|
|
final pct = ((last - prev) / prev * 100).toStringAsFixed(1);
|
|
weekChange = '${double.parse(pct) >= 0 ? '+' : ''}$pct% عن الأسبوع السابق';
|
|
weekColor =
|
|
double.parse(pct) >= 0 ? AppColor.success : AppColor.danger;
|
|
}
|
|
}
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
GridView.count(
|
|
crossAxisCount: MediaQuery.of(context).size.width > 600 ? 3 : 2,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 10,
|
|
crossAxisSpacing: 10,
|
|
childAspectRatio: 1.8,
|
|
children: [
|
|
_StatCard(
|
|
label: 'إجمالي الركاب',
|
|
value: _fmt(totals['passengers']),
|
|
icon: Icons.people_rounded,
|
|
),
|
|
_StatCard(
|
|
label: 'إجمالي الكباتن',
|
|
value: _fmt(totals['drivers']),
|
|
icon: Icons.drive_eta_rounded,
|
|
),
|
|
if (weeks.isNotEmpty)
|
|
_StatCard(
|
|
label: 'رحلات آخر أسبوع',
|
|
value: _fmt(weeks.last['rides']),
|
|
sub: weekChange,
|
|
subColor: weekColor,
|
|
icon: Icons.trending_up_rounded,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
_buildGrowthChart(),
|
|
_buildWeeklyChart(weeks),
|
|
_buildRetentionChart(),
|
|
_buildRevenueChart(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildGrowthChart() {
|
|
final passengers = (ctrl.growth['passengers'] as List?) ?? [];
|
|
final drivers = (ctrl.growth['drivers'] as List?) ?? [];
|
|
if (passengers.isEmpty && drivers.isEmpty) return const SizedBox.shrink();
|
|
|
|
final maxLen = passengers.length > drivers.length
|
|
? passengers.length
|
|
: drivers.length;
|
|
|
|
return _ChartCard(
|
|
title: 'نمو المسجّلين الجدد (30 يوم)',
|
|
height: 250,
|
|
child: LineChart(
|
|
LineChartData(
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: passengers.asMap().entries.map((e) {
|
|
return FlSpot(e.key.toDouble(), _toDouble(e.value['count']));
|
|
}).toList(),
|
|
color: AppColor.accent,
|
|
barWidth: 2,
|
|
dotData: const FlDotData(show: false),
|
|
isCurved: true,
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: AppColor.accent.withOpacity(0.08),
|
|
),
|
|
),
|
|
LineChartBarData(
|
|
spots: drivers.asMap().entries.map((e) {
|
|
return FlSpot(e.key.toDouble(), _toDouble(e.value['count']));
|
|
}).toList(),
|
|
color: const Color(0xFF00CEC9),
|
|
barWidth: 2,
|
|
dotData: const FlDotData(show: false),
|
|
isCurved: true,
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: const Color(0xFF00CEC9).withOpacity(0.08),
|
|
),
|
|
),
|
|
],
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: (maxLen / 6).ceilToDouble(),
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= passengers.length) return const SizedBox();
|
|
final d = passengers[i]['date']?.toString() ?? '';
|
|
return Text(d.length >= 10 ? d.substring(5) : d,
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWeeklyChart(List weeks) {
|
|
if (weeks.isEmpty) return const SizedBox.shrink();
|
|
|
|
return _ChartCard(
|
|
title: 'مقارنة أسبوع بأسبوع',
|
|
child: BarChart(
|
|
BarChartData(
|
|
barGroups: weeks.asMap().entries.map((e) {
|
|
return BarChartGroupData(x: e.key, barRods: [
|
|
BarChartRodData(
|
|
toY: _toDouble(e.value['rides']),
|
|
color: AppColor.accent.withOpacity(0.7),
|
|
width: 12,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(4)),
|
|
),
|
|
]);
|
|
}).toList(),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= weeks.length) return const SizedBox();
|
|
final d = weeks[i]['week_start']?.toString() ?? '';
|
|
return Text(d.length >= 10 ? d.substring(5) : d,
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRetentionChart() {
|
|
final cohorts = (ctrl.retention['cohorts'] as List?) ?? [];
|
|
if (cohorts.isEmpty) return const SizedBox.shrink();
|
|
|
|
return _ChartCard(
|
|
title: 'احتفاظ الركاب (Retention Cohort)',
|
|
child: BarChart(
|
|
BarChartData(
|
|
barGroups: cohorts.asMap().entries.map((e) {
|
|
final opacity = (1.0 - e.key * 0.05).clamp(0.3, 1.0);
|
|
return BarChartGroupData(x: e.key, barRods: [
|
|
BarChartRodData(
|
|
toY: _toDouble(e.value['active_passengers']),
|
|
color: AppColor.accent.withOpacity(opacity),
|
|
width: 14,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(4)),
|
|
),
|
|
]);
|
|
}).toList(),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (v, _) {
|
|
return Text('w${v.toInt()}',
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRevenueChart() {
|
|
final daily = (ctrl.revenue['daily'] as List?) ?? [];
|
|
if (daily.isEmpty) return const SizedBox.shrink();
|
|
|
|
return _ChartCard(
|
|
title: 'الإيرادات اليومية (30 يوم)',
|
|
height: 250,
|
|
child: LineChart(
|
|
LineChartData(
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: daily.asMap().entries.map((e) {
|
|
return FlSpot(
|
|
e.key.toDouble(), _toDouble(e.value['total_revenue']));
|
|
}).toList(),
|
|
color: AppColor.info,
|
|
barWidth: 2,
|
|
dotData: const FlDotData(show: false),
|
|
isCurved: true,
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: AppColor.info.withOpacity(0.08),
|
|
),
|
|
),
|
|
],
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: (daily.length / 6).ceilToDouble(),
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= daily.length) return const SizedBox();
|
|
final d = daily[i]['date']?.toString() ?? '';
|
|
return Text(d.length >= 10 ? d.substring(5) : d,
|
|
style: const TextStyle(
|
|
fontSize: 9, color: AppColor.textMuted));
|
|
},
|
|
),
|
|
),
|
|
leftTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
getDrawingHorizontalLine: (_) =>
|
|
FlLine(color: AppColor.divider, strokeWidth: 0.5),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|