From 8b19adeb8083b703c0bde273723ccf19c7f9e02f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 11 Jul 2026 00:49:03 +0300 Subject: [PATCH] Update: 2026-07-11 00:49:03 --- siro_admin/lib/constant/links.dart | 4 +- .../admin/live_analytics_controller.dart | 75 + .../lib/views/admin/admin_home_page.dart | 10 +- .../admin/analytics/live_analytics_page.dart | 1336 +++++++++++++++++ 4 files changed, 1416 insertions(+), 9 deletions(-) create mode 100644 siro_admin/lib/controller/admin/live_analytics_controller.dart create mode 100644 siro_admin/lib/views/admin/analytics/live_analytics_page.dart diff --git a/siro_admin/lib/constant/links.dart b/siro_admin/lib/constant/links.dart index 3f0c8839..d9f35810 100644 --- a/siro_admin/lib/constant/links.dart +++ b/siro_admin/lib/constant/links.dart @@ -411,8 +411,8 @@ class AppLink { "$server/Admin/v2/quality/blacklist_manager.php"; static String driverScorecard = "$server/Admin/v2/quality/driver_scorecard.php"; - static String analyticsDashboard = - "$server/Admin/v2/analytics/dashboard/index.php"; + static String analyticsDashboardData = + "$server/Admin/v2/analytics/dashboard_data.php"; static String getEmployee = "$server/Admin/employee/get.php"; static String getBestDriver = "$server/Admin/driver/getBestDriver.php"; static String getBestDriverGiza = diff --git a/siro_admin/lib/controller/admin/live_analytics_controller.dart b/siro_admin/lib/controller/admin/live_analytics_controller.dart new file mode 100644 index 00000000..0c4793e1 --- /dev/null +++ b/siro_admin/lib/controller/admin/live_analytics_controller.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; +import 'package:get/get.dart'; +import 'package:siro_admin/constant/links.dart'; +import 'package:siro_admin/controller/functions/crud.dart'; +import '../../print.dart'; + +class LiveAnalyticsController extends GetxController { + bool isLoading = true; + String selectedDate = ''; + + Map allData = {}; + + Map get realtime => _section('realtime'); + Map get gap => _section('gap'); + Map get heatmap => _section('heatmap'); + Map get pricing => _section('pricing'); + Map get revenue => _section('revenue'); + Map get growth => _section('growth'); + Map get market => _section('market'); + Map get complaints => _section('complaints'); + Map get funnel => _section('funnel'); + Map get hourly => _section('hourly'); + Map get weekly => _section('weekly'); + Map get zones => _section('zones'); + Map get retention => _section('retention'); + Map get competitor => _section('competitor'); + + List availableDates = []; + + Map _section(String key) { + final d = allData[key]; + if (d is Map) return d; + return {}; + } + + @override + void onInit() { + super.onInit(); + final now = DateTime.now(); + selectedDate = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + fetchAll(); + } + + Future fetchAll() async { + isLoading = true; + update(); + + try { + var res = await CRUD().get( + link: AppLink.analyticsDashboardData, + payload: {'date': selectedDate, 'section': 'all'}, + ); + + if (res != 'failure' && res != null) { + var d = res is String ? jsonDecode(res) : res; + if (d['status'] == 'success' && d['data'] != null) { + allData = Map.from(d['data']); + availableDates = + List.from(d['available_dates'] ?? []); + } + } + } catch (e) { + Log.print('Error fetching live analytics: $e'); + } + + isLoading = false; + update(); + } + + void changeDate(String date) { + selectedDate = date; + fetchAll(); + } +} diff --git a/siro_admin/lib/views/admin/admin_home_page.dart b/siro_admin/lib/views/admin/admin_home_page.dart index 7df00017..e99eabbb 100644 --- a/siro_admin/lib/views/admin/admin_home_page.dart +++ b/siro_admin/lib/views/admin/admin_home_page.dart @@ -35,8 +35,7 @@ import 'dashboard_v2_widget.dart'; import 'static/advanced_analytics_page.dart'; import 'financial/financial_v2_page.dart'; import 'security/audit_logs_page.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../../constant/links.dart'; +import 'analytics/live_analytics_page.dart'; class AdminHomePage extends StatefulWidget { const AdminHomePage({super.key}); @@ -781,11 +780,8 @@ class _AdminHomePageState extends State ActionItem('التحليلات المتقدمة', Icons.analytics_rounded, _info, () => Get.to(() => const AdvancedAnalyticsPage())), ActionItem('لوحة البيانات التفاعلية', Icons.dashboard_customize_rounded, - const Color(0xFF00CEC9), () async { - final token = box.read(BoxName.jwt) ?? ''; - final url = '${AppLink.analyticsDashboard}?token=$token'; - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - }), + const Color(0xFF00CEC9), + () => Get.to(() => const LiveAnalyticsPage())), ], ), ActionCategory( diff --git a/siro_admin/lib/views/admin/analytics/live_analytics_page.dart b/siro_admin/lib/views/admin/analytics/live_analytics_page.dart new file mode 100644 index 00000000..ef778c3b --- /dev/null +++ b/siro_admin/lib/views/admin/analytics/live_analytics_page.dart @@ -0,0 +1,1336 @@ +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( + 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 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 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 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 = {}; + 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 = {}; + 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 = []; + 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 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 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(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? ?? {}; + 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), + ), + ), + ); + } +}