Files
Siro/siro_admin/lib/views/admin/analytics/live_analytics_page.dart
T

1355 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 '../../../controller/admin/live_analytics_controller.dart';
class LiveAnalyticsPage extends StatelessWidget {
const LiveAnalyticsPage({super.key});
@override
Widget build(BuildContext context) {
final c = Get.put(LiveAnalyticsController());
final cs = Theme.of(context).colorScheme;
return DefaultTabController(
length: 4,
child: Scaffold(
backgroundColor: cs.surface,
appBar: AppBar(
title: const Text('لوحة البيانات التفاعلية',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
backgroundColor: cs.surfaceContainerHighest,
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: TabBar(
isScrollable: true,
indicatorColor: cs.primary,
labelColor: cs.primary,
unselectedLabelColor: cs.onSurfaceVariant,
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
tabs: const [
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 Center(
child: CircularProgressIndicator(color: cs.primary),
);
}
if (ctrl.allData.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off_rounded,
size: 48, color: cs.onSurfaceVariant),
const SizedBox(height: 12),
Text('لا توجد بيانات لتاريخ ${ctrl.selectedDate}',
style: TextStyle(color: cs.onSurfaceVariant)),
const SizedBox(height: 8),
if (ctrl.availableDates.isNotEmpty)
Text('آخر تاريخ متاح: ${ctrl.availableDates.first}',
style: TextStyle(
color: cs.primary, 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 cs = Theme.of(context).colorScheme;
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: ColorScheme.dark(primary: cs.primary),
),
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) {
final cs = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 6),
],
Expanded(
child: Text(label,
style: TextStyle(
fontSize: 11,
color: cs.onSurfaceVariant,
fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
],
),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: cs.onSurface)),
),
if (sub != null) ...[
const SizedBox(height: 2),
Text(sub!,
style: TextStyle(
fontSize: 10,
color: subColor ?? cs.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12, top: 8),
child: Text(title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: cs.onSurface)),
);
}
}
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) {
final cs = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.onSurfaceVariant)),
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 cs = Theme.of(context).colorScheme;
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: [
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 ? const Color(0xFF10B981) : cs.error,
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(cs),
const SizedBox(height: 8),
_buildMap(c, cs),
const SizedBox(height: 20),
_buildHourlyChart(c, cs),
_buildFunnelChart(c, cs),
],
);
}
Widget _buildLayerToggles(ColorScheme cs) {
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, const Color(0xFF10B981),
(v) => setState(() => showSupply = v)),
_LayerChip('أسعار المنافسين', showPricing, cs.tertiary,
(v) => setState(() => showPricing = v)),
_LayerChip('أعلى المناطق', showZones, cs.primary,
(v) => setState(() => showZones = v)),
],
);
}
Widget _buildMap(LiveAnalyticsController c, ColorScheme cs) {
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.withValues(alpha: 0.3 + intensity * 0.5)
: const Color(0xFF10B981).withValues(alpha: 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.withValues(alpha: 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: const Color(0xFF10B981).withValues(alpha: 0.5),
borderStrokeWidth: 1,
borderColor: Colors.white.withValues(alpha: 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: cs.tertiary.withValues(alpha: 0.5),
borderStrokeWidth: 1,
borderColor: Colors.white.withValues(alpha: 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: cs.primary.withValues(alpha: 0.5),
borderStrokeWidth: 2,
borderColor: Colors.white.withValues(alpha: 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'],
userAgentPackageName: 'com.siromove.admin',
),
CircleLayer(circles: markers),
],
),
),
);
}
Widget _buildHourlyChart(LiveAnalyticsController c, ColorScheme cs) {
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: cs.primary.withValues(alpha: 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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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, ColorScheme cs) {
final statuses = (c.funnel['statuses'] as List?) ?? [];
if (statuses.isEmpty) return const SizedBox.shrink();
final colors = {
'Finished': const Color(0xFF10B981),
'wait': cs.tertiary,
'started': cs.tertiary,
'arrived': cs.primary,
'cancelled': cs.error,
'timeout': cs.onSurfaceVariant,
};
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] ?? cs.onSurfaceVariant,
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) {
final cs = Theme.of(context).colorScheme;
return FilterChip(
label: Text(label,
style: TextStyle(
fontSize: 12,
color: active ? Colors.white : cs.onSurfaceVariant)),
selected: active,
onSelected: onChanged,
selectedColor: color.withValues(alpha: 0.3),
checkmarkColor: color,
backgroundColor: cs.surfaceContainerHighest,
side: BorderSide(
color: active ? color.withValues(alpha: 0.5) : cs.outline),
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 cs = Theme.of(context).colorScheme;
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(cs),
_buildPciChart(countries, cs),
_buildMarketShareChart(countries, cs),
],
);
}
Widget _buildCompetitorChart(ColorScheme cs) {
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 = [
cs.error,
cs.tertiary,
cs.tertiary,
const Color(0xFF10B981),
cs.primary,
];
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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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: cs.outline, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
),
),
);
}
Widget _buildPciChart(Map<String, dynamic> countries, ColorScheme cs) {
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: cs.primary,
barWidth: 2,
dotData: const FlDotData(show: true),
isCurved: true,
belowBarData: BarAreaData(
show: true,
color: cs.primary.withValues(alpha: 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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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, ColorScheme cs) {
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).withValues(alpha: 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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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 cs = Theme.of(context).colorScheme;
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, cs),
_buildRevenueProfitChart(cs),
if (topZones.isNotEmpty) ...[
const _SectionTitle('أعلى المناطق نشاطاً (آخر 4 أسابيع)'),
_buildZonesTable(topZones, cs),
],
],
);
}
Widget _buildComplaintsChart(List complaintsList, ColorScheme cs) {
if (complaintsList.isEmpty) return const SizedBox.shrink();
final colors = [
cs.error,
cs.tertiary,
cs.tertiary,
cs.primary,
const Color(0xFF10B981),
cs.onSurfaceVariant,
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(ColorScheme cs) {
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: cs.tertiary,
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: const Color(0xFF10B981),
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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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: cs.outline, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
),
),
);
}
Widget _buildZonesTable(List topZones, ColorScheme cs) {
return Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.outline),
),
child: DataTable(
columnSpacing: 20,
headingRowColor: WidgetStateProperty.all(cs.surfaceContainerHighest),
columns: [
const DataColumn(
label: Text('#',
style:
TextStyle(fontSize: 12))),
const DataColumn(
label: Text('الموقع',
style:
TextStyle(fontSize: 12))),
const DataColumn(
label: Text('رحلات',
style:
TextStyle(fontSize: 12))),
const DataColumn(
label: Text('متوسط السعر',
style:
TextStyle(fontSize: 12))),
],
rows: topZones.asMap().entries.map((e) {
final z = e.value;
return DataRow(cells: [
DataCell(Text('${e.key + 1}',
style: const TextStyle(
fontSize: 12))),
DataCell(Text(
'${_toDouble(z['lat']).toStringAsFixed(4)}, ${_toDouble(z['lng']).toStringAsFixed(4)}',
style: const TextStyle(
fontSize: 12))),
DataCell(Text(_fmt(z['rides']),
style: const TextStyle(
fontSize: 12))),
DataCell(Text(_toDouble(z['avg_price']).toStringAsFixed(2),
style: const TextStyle(
fontSize: 12))),
]);
}).toList(),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// TAB 4: النمو والاحتفاظ
// ═══════════════════════════════════════════════════════════════
class _GrowthTab extends StatelessWidget {
final LiveAnalyticsController ctrl;
const _GrowthTab({required this.ctrl});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final gr = ctrl.growth;
final totals = gr['totals'] as Map<String, dynamic>? ?? {};
final weeks = (ctrl.weekly['weeks'] as List?) ?? [];
String weekChange = '';
Color weekColor = cs.onSurfaceVariant;
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 ? const Color(0xFF10B981) : cs.error;
}
}
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(cs),
_buildWeeklyChart(weeks, cs),
_buildRetentionChart(cs),
_buildRevenueChart(cs),
],
);
}
Widget _buildGrowthChart(ColorScheme cs) {
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: cs.primary,
barWidth: 2,
dotData: const FlDotData(show: false),
isCurved: true,
belowBarData: BarAreaData(
show: true,
color: cs.primary.withValues(alpha: 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).withValues(alpha: 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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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, ColorScheme cs) {
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: cs.primary.withValues(alpha: 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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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(ColorScheme cs) {
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: cs.primary.withValues(alpha: 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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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(ColorScheme cs) {
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: cs.tertiary,
barWidth: 2,
dotData: const FlDotData(show: false),
isCurved: true,
belowBarData: BarAreaData(
show: true,
color: cs.tertiary.withValues(alpha: 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: TextStyle(
fontSize: 9, color: cs.onSurfaceVariant));
},
),
),
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: cs.outline, strokeWidth: 0.5),
),
borderData: FlBorderData(show: false),
),
),
);
}
}