Files
Siro/siro_admin/lib/views/admin/static/static.dart
2026-06-09 08:40:31 +03:00

1327 lines
49 KiB
Dart

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:siro_admin/controller/admin/static_controller.dart';
import 'notes_driver_page.dart';
// ══════════════════════════════════════════════════════════════
// DESIGN TOKENS
// ══════════════════════════════════════════════════════════════
const Color _bg = Color(0xFF0D1117);
const Color _surface = Color(0xFF161B22);
const Color _surfaceElevated = Color(0xFF1C2333);
const Color _accent = Color(0xFF00D4AA);
const Color _danger = Color(0xFFFF5370);
const Color _warning = Color(0xFFFFCB6B);
const Color _info = Color(0xFF82AAFF);
const Color _purple = Color(0xFFC792EA);
const Color _textPrimary = Color(0xFFE6EDF3);
const Color _textSecondary = Color(0xFF7D8590);
const Color _divider = Color(0xFF21262D);
class StaticDash extends StatelessWidget {
const StaticDash({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.put(StaticController());
return Scaffold(
backgroundColor: _bg,
body: GetBuilder<StaticController>(
builder: (c) {
return CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
// ── App Bar ────────────────────────────────────
_SliverHeader(controller: c),
if (c.isLoading)
const SliverFillRemaining(child: _LoadingState())
else ...[
// ── Date Badge ─────────────────────────────
SliverToBoxAdapter(
child: _DateBadge(controller: c),
),
// ── KPI Row ────────────────────────────────
SliverToBoxAdapter(
child: _KpiRow(controller: c),
),
// ── Section: Growth ────────────────────────
const SliverToBoxAdapter(
child: _SectionLabel(
'نظرة عامة على النمو',
Icons.trending_up_rounded,
),
),
SliverToBoxAdapter(
child: AnimationLimiter(
child: Column(
children: AnimationConfiguration.toStaggeredList(
duration: const Duration(milliseconds: 450),
childAnimationBuilder: (w) => SlideAnimation(
verticalOffset: 40,
child: FadeInAnimation(child: w),
),
children: [
_DarkChartCard(
title: 'الركاب',
total: c.totalMonthlyPassengers,
spots: c.chartDataPassengers,
compareSpots: c.isComparing
? c.chartDataPassengersCompare
: null,
color: _info,
icon: Icons.groups_rounded,
controller: c,
),
const SizedBox(height: 12),
_DarkChartCard(
title: 'السائقون',
total: c.totalMonthlyDrivers,
spots: c.chartDataDrivers,
compareSpots: c.isComparing
? c.chartDataDriversCompare
: null,
color: _warning,
icon: Icons.drive_eta_rounded,
controller: c,
),
const SizedBox(height: 12),
_DarkChartCard(
title: 'الرحلات',
total: c.totalMonthlyRides,
spots: c.chartDataRides,
compareSpots:
c.isComparing ? c.chartDataRidesCompare : null,
color: _purple,
icon: Icons.map_rounded,
controller: c,
),
const SizedBox(height: 24),
],
),
),
),
),
// ── Section: Team Performance ──────────────
const SliverToBoxAdapter(
child: _SectionLabel(
'أداء فريق العمل',
Icons.workspaces_filled,
),
),
SliverToBoxAdapter(
child: Column(
children: [
// Activations
_DynamicMultiLineCard(
title: 'تفعيل السائقين',
subtitle: 'Activations',
icon: Icons.how_to_reg_rounded,
controller: c,
getSpots: (emp) => emp.notesSpots,
getCompareSpots: (emp) =>
c.employeeDataCompare[emp.name]?.notesSpots ?? [],
getTotal: (emp) => emp.totalNotes,
),
const SizedBox(height: 12),
// Calls
_DynamicMultiLineCard(
title: 'عدد المكالمات',
subtitle: 'Calls',
icon: Icons.phone_in_talk_rounded,
controller: c,
getSpots: (emp) => emp.callsSpots,
getCompareSpots: (emp) =>
c.employeeDataCompare[emp.name]?.callsSpots ?? [],
getTotal: (emp) => emp.totalCalls,
onTap: () => Get.to(() => const DailyNotesView()),
),
const SizedBox(height: 24),
],
),
),
// ── Section: Employee Leaderboard ──────────
if (c.employmentStatsList.isNotEmpty) ...[
const SliverToBoxAdapter(
child: _SectionLabel(
'لوحة الصدارة',
Icons.emoji_events_rounded,
),
),
SliverToBoxAdapter(
child: _EmployeeLeaderboard(stats: c.employmentStatsList),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
// ── Section: Registration Follow-up ────────
const SliverToBoxAdapter(
child: _SectionLabel(
'متابعة التسجيل',
Icons.assignment_turned_in_rounded,
),
),
SliverToBoxAdapter(
child: Column(
children: [
_DarkChartCard(
title: 'سائقين بعد الاتصال',
total: c.staticList.isNotEmpty &&
c.staticList[0]['totalMonthlyMatchingNotes'] !=
null
? c.staticList[0]['totalMonthlyMatchingNotes']
.toString()
: '0',
spots: c.chartDataDriversMatchingNotes,
compareSpots: c.isComparing
? c.chartDataDriversMatchingNotesCompare
: null,
color: _accent,
icon: Icons.phone_in_talk_rounded,
controller: c,
),
const SizedBox(height: 60),
],
),
),
],
],
);
},
),
);
}
}
// ══════════════════════════════════════════════════════════════
// SLIVER HEADER
// ══════════════════════════════════════════════════════════════
class _SliverHeader extends StatelessWidget {
final StaticController controller;
const _SliverHeader({required this.controller});
@override
Widget build(BuildContext context) {
return SliverAppBar(
expandedHeight: 100,
pinned: true,
floating: true,
backgroundColor: _bg,
elevation: 0,
leading: GestureDetector(
onTap: () => Get.back(),
child: Container(
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _divider),
),
child: const Icon(Icons.arrow_back_ios_new_rounded,
color: _textSecondary, size: 16),
),
),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F1F1A), _bg],
),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _accent.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _accent.withOpacity(0.25)),
),
child: const Icon(Icons.bar_chart_rounded,
color: _accent, size: 18),
),
const SizedBox(width: 10),
ShaderMask(
shaderCallback: (b) => const LinearGradient(
colors: [_accent, _info],
).createShader(b),
child: const Text(
'لوحة الإحصائيات',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
),
),
actions: [
GestureDetector(
onTap: () async => await controller.getAll(),
child: Container(
margin: const EdgeInsets.only(right: 16, top: 10, bottom: 10),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _divider),
),
child: const Icon(Icons.refresh_rounded,
color: _textSecondary, size: 18),
),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(height: 1, color: _divider),
),
);
}
}
// ══════════════════════════════════════════════════════════════
// DATE BADGE
// ══════════════════════════════════════════════════════════════
class _DateBadge extends StatelessWidget {
final StaticController controller;
const _DateBadge({required this.controller});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _divider),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.calendar_today_rounded,
color: _accent, size: 13),
const SizedBox(width: 7),
Text(
controller.currentDateString,
style: const TextStyle(
color: _textSecondary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
if (controller.isComparing) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: _textSecondary,
),
),
const SizedBox(width: 7),
Text(
controller.compareDateString,
style: const TextStyle(
color: _textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500),
),
],
),
),
]
],
),
);
}
}
// ══════════════════════════════════════════════════════════════
// KPI ROW
// ══════════════════════════════════════════════════════════════
class _KpiRow extends StatelessWidget {
final StaticController controller;
const _KpiRow({required this.controller});
@override
Widget build(BuildContext context) {
final items = [
_KpiItem('الركاب', controller.totalMonthlyPassengers,
Icons.groups_rounded, _info),
_KpiItem('السائقون', controller.totalMonthlyDrivers,
Icons.drive_eta_rounded, _warning),
_KpiItem(
'الرحلات', controller.totalMonthlyRides, Icons.map_rounded, _purple),
];
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: items
.map((item) => Expanded(
child: Padding(
padding: EdgeInsets.only(right: item != items.last ? 8 : 0),
child: _buildKpiCard(item),
),
))
.toList(),
),
);
}
Widget _buildKpiCard(_KpiItem item) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: item.color.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(item.icon, color: item.color, size: 18),
const SizedBox(height: 8),
Text(
item.value,
style: const TextStyle(
color: _textPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
height: 1,
),
),
const SizedBox(height: 3),
Text(item.label,
style: const TextStyle(color: _textSecondary, fontSize: 10)),
],
),
);
}
}
class _KpiItem {
final String label, value;
final IconData icon;
final Color color;
const _KpiItem(this.label, this.value, this.icon, this.color);
}
// ══════════════════════════════════════════════════════════════
// SECTION LABEL
// ══════════════════════════════════════════════════════════════
class _SectionLabel extends StatelessWidget {
final String text;
final IconData icon;
const _SectionLabel(this.text, this.icon);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 12),
child: Row(
children: [
Container(
width: 3,
height: 16,
decoration: BoxDecoration(
color: _accent,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Icon(icon, size: 16, color: _textSecondary),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
color: _textPrimary,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
],
),
);
}
}
// ══════════════════════════════════════════════════════════════
// DARK SINGLE-LINE CHART CARD
// ══════════════════════════════════════════════════════════════
class _DarkChartCard extends StatelessWidget {
final String title, total;
final List<FlSpot> spots;
final List<FlSpot>? compareSpots;
final Color color;
final IconData icon;
final StaticController controller;
const _DarkChartCard({
required this.title,
required this.total,
required this.spots,
this.compareSpots,
required this.color,
required this.icon,
required this.controller,
});
@override
Widget build(BuildContext context) {
final allSpots = [...spots, ...?compareSpots];
final maxY = _maxY(allSpots);
final interval = _interval(maxY);
final days = _days(controller);
final xInterval = _xInterval(days);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
height: 340,
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withOpacity(0.15)),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.06),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(18),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Icon(icon, color: color, size: 18),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
color: _textPrimary,
fontSize: 14,
fontWeight: FontWeight.w700)),
Text('الإجمالي: $total',
style: const TextStyle(
color: _textSecondary, fontSize: 11)),
]),
]),
if (controller.isComparing)
Row(children: [
Container(
width: 16,
height: 2,
decoration: BoxDecoration(
color: _textSecondary,
borderRadius: BorderRadius.circular(1),
)),
const SizedBox(width: 5),
const Text('سابق',
style:
TextStyle(color: _textSecondary, fontSize: 10)),
]),
],
),
),
// Chart
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 12, 10),
child: Directionality(
textDirection: TextDirection.ltr,
child: LineChart(LineChartData(
minX: 1,
maxX: days > 0 ? days : 1,
minY: 0,
maxY: maxY,
lineTouchData: _tooltipData(controller, color),
gridData: _gridData(interval),
titlesData:
_titlesData(interval, xInterval, days, controller),
borderData: FlBorderData(show: false),
lineBarsData: [
if (compareSpots != null && compareSpots!.isNotEmpty)
_line(compareSpots!, _textSecondary,
isDashed: true, isStep: true, width: 1.5),
_line(spots, color, isStep: true, width: 2.5),
],
)),
),
),
),
// Control Bar
_ControlBar(controller: controller, accentColor: color),
],
),
),
);
}
}
// ══════════════════════════════════════════════════════════════
// DYNAMIC MULTI-LINE CHART CARD 🔥 Uses dynamic employee data
// ══════════════════════════════════════════════════════════════
class _DynamicMultiLineCard extends StatelessWidget {
final String title, subtitle;
final IconData icon;
final StaticController controller;
final List<FlSpot> Function(EmployeeChartData) getSpots;
final List<FlSpot> Function(EmployeeChartData) getCompareSpots;
final int Function(EmployeeChartData) getTotal;
final VoidCallback? onTap;
const _DynamicMultiLineCard({
required this.title,
required this.subtitle,
required this.icon,
required this.controller,
required this.getSpots,
required this.getCompareSpots,
required this.getTotal,
this.onTap,
});
@override
Widget build(BuildContext context) {
final employees = controller.employeeData.values.toList();
// Sort by total descending for legend ordering
employees.sort((a, b) => getTotal(b).compareTo(getTotal(a)));
final allSpots = employees.expand((e) => getSpots(e)).toList();
final maxY = _maxY(allSpots);
final interval = _interval(maxY);
final days = _days(controller);
final xInterval = _xInterval(days);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _divider),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 15,
offset: const Offset(0, 6),
),
],
),
child: Column(
children: [
// ── Header ──────────────────────────────────────
InkWell(
onTap: onTap,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: _accent.withOpacity(0.10),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _accent.withOpacity(0.20)),
),
child: Icon(icon, color: _accent, size: 18),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
color: _textPrimary,
fontSize: 14,
fontWeight: FontWeight.w700)),
Text(subtitle,
style: const TextStyle(
color: _textSecondary, fontSize: 11)),
if (onTap != null)
const Text(
'اضغط لعرض التفاصيل اليومية',
style: TextStyle(
color: _accent,
fontSize: 9,
fontWeight: FontWeight.w600),
),
]),
]),
if (onTap != null)
const Icon(Icons.arrow_forward_ios_rounded,
size: 14, color: _textSecondary),
],
),
),
),
// ── Dynamic Legend ──────────────────────────────
if (employees.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Text('لا توجد بيانات',
style: TextStyle(color: _textSecondary)),
)
else ...[
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 4),
child: Wrap(
spacing: 10,
runSpacing: 8,
children: employees.map((emp) {
return _LegendChip(
name: emp.name.toUpperCase(),
color: emp.color,
total: getTotal(emp),
);
}).toList(),
),
),
const SizedBox(height: 8),
// ── Chart ───────────────────────────────────
SizedBox(
height: 240,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 12, 10),
child: Directionality(
textDirection: TextDirection.ltr,
child: LineChart(LineChartData(
minX: 1,
maxX: days > 0 ? days : 1,
minY: 0,
maxY: maxY,
lineTouchData: _tooltipData(controller, _accent),
gridData: _gridData(interval),
titlesData:
_titlesData(interval, xInterval, days, controller),
borderData: FlBorderData(show: false),
lineBarsData: [
// Compare lines (dashed)
if (controller.isComparing)
...employees.map((emp) => _line(
getCompareSpots(emp),
emp.color.withOpacity(0.25),
isDashed: true,
isStep: false,
width: 1.5,
)),
// Main lines
...employees.map((emp) => _line(
getSpots(emp),
emp.color,
isStep: false,
width: 2.5,
)),
],
)),
),
),
),
],
_ControlBar(controller: controller, accentColor: _accent),
],
),
),
);
}
}
// ══════════════════════════════════════════════════════════════
// LEGEND CHIP
// ══════════════════════════════════════════════════════════════
class _LegendChip extends StatelessWidget {
final String name;
final Color color;
final int total;
const _LegendChip(
{required this.name, required this.color, required this.total});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(name,
style: TextStyle(
color: color, fontSize: 10, fontWeight: FontWeight.w700)),
const SizedBox(width: 5),
Text('$total',
style: const TextStyle(
color: _textSecondary,
fontSize: 10,
fontWeight: FontWeight.w500)),
],
),
);
}
}
// ══════════════════════════════════════════════════════════════
// EMPLOYEE LEADERBOARD 🔥 Fully dynamic
// ══════════════════════════════════════════════════════════════
class _EmployeeLeaderboard extends StatelessWidget {
final List<EmploymentStat> stats;
const _EmployeeLeaderboard({required this.stats});
@override
Widget build(BuildContext context) {
final maxCount = stats.isEmpty ? 1 : stats.first.count;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _divider),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _warning.withOpacity(0.12),
borderRadius: BorderRadius.circular(9),
border: Border.all(color: _warning.withOpacity(0.25)),
),
child: const Icon(Icons.emoji_events_rounded,
color: _warning, size: 16),
),
const SizedBox(width: 10),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('لوحة الصدارة',
style: TextStyle(
color: _textPrimary,
fontSize: 14,
fontWeight: FontWeight.w700)),
Text('إجمالي الإدخالات حسب الموظف',
style:
TextStyle(color: _textSecondary, fontSize: 10)),
],
),
]),
Text(
'${stats.fold(0, (s, e) => s + e.count)} إجمالي',
style: const TextStyle(
color: _textSecondary,
fontSize: 11,
fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 20),
// Rank rows
...List.generate(stats.length, (i) {
final stat = stats[i];
final pct = maxCount > 0 ? stat.count / maxCount : 0.0;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
children: [
Row(
children: [
// Rank Badge
_RankBadge(rank: i + 1, color: stat.color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
stat.name.toUpperCase(),
style: TextStyle(
color: stat.color,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
Text(
'${stat.count}',
style: const TextStyle(
color: _textPrimary,
fontSize: 14,
fontWeight: FontWeight.w800,
fontFamily: 'monospace',
),
),
],
),
const SizedBox(height: 6),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct.toDouble(),
backgroundColor: _divider,
valueColor:
AlwaysStoppedAnimation(stat.color),
minHeight: 5,
),
),
],
),
),
],
),
],
),
);
}),
],
),
),
);
}
}
class _RankBadge extends StatelessWidget {
final int rank;
final Color color;
const _RankBadge({required this.rank, required this.color});
@override
Widget build(BuildContext context) {
final isTop3 = rank <= 3;
final medalColors = [_warning, _textSecondary, const Color(0xFFCD7F32)];
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isTop3 ? medalColors[rank - 1].withOpacity(0.12) : _divider,
shape: BoxShape.circle,
border: Border.all(
color: isTop3
? medalColors[rank - 1].withOpacity(0.4)
: Colors.transparent),
),
child: Center(
child: Text(
'$rank',
style: TextStyle(
color: isTop3 ? medalColors[rank - 1] : _textSecondary,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
);
}
}
// ══════════════════════════════════════════════════════════════
// SHARED CONTROL BAR
// ══════════════════════════════════════════════════════════════
class _ControlBar extends StatelessWidget {
final StaticController controller;
final Color accentColor;
const _ControlBar({required this.controller, required this.accentColor});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: _bg,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)),
border: const Border(top: BorderSide(color: _divider)),
),
child: Row(
children: [
Expanded(
child: InkWell(
onTap: () async {
final now = DateTime.now();
final currentEnd = controller.endDate ?? now;
final lastDate = currentEnd.isAfter(now)
? currentEnd.add(const Duration(days: 1))
: now.add(const Duration(days: 1));
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: lastDate,
initialDateRange: DateTimeRange(
start: controller.startDate ??
now.subtract(const Duration(days: 30)),
end: currentEnd,
),
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: const ColorScheme.dark(
primary: _accent,
onPrimary: _bg,
surface: _surfaceElevated,
onSurface: _textPrimary,
),
),
child: child!,
),
);
if (picked != null) {
controller.updateDateRange(picked.start, picked.end);
}
},
borderRadius: BorderRadius.circular(9),
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(9),
border: Border.all(color: _divider),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.calendar_today_rounded,
size: 13, color: accentColor),
const SizedBox(width: 7),
Text(
_formatRange(controller),
style: const TextStyle(
color: _textSecondary,
fontSize: 11,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
const SizedBox(width: 10),
InkWell(
onTap: controller.toggleComparison,
borderRadius: BorderRadius.circular(9),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration(
color: controller.isComparing
? _danger.withOpacity(0.10)
: _surface,
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: controller.isComparing
? _danger.withOpacity(0.3)
: _divider),
),
child: Row(
children: [
Icon(
controller.isComparing
? Icons.visibility_off_outlined
: Icons.compare_arrows_rounded,
size: 14,
color: controller.isComparing ? _danger : accentColor,
),
const SizedBox(width: 5),
Text(
controller.isComparing ? 'إلغاء' : 'مقارنة',
style: TextStyle(
color: controller.isComparing ? _danger : accentColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
);
}
String _formatRange(StaticController c) {
if (c.startDate == null || c.endDate == null) return c.currentDateString;
return "${c.startDate!.day}/${c.startDate!.month}/${c.startDate!.year}"
" - ${c.endDate!.day}/${c.endDate!.month}/${c.endDate!.year}";
}
}
// ══════════════════════════════════════════════════════════════
// LOADING STATE
// ══════════════════════════════════════════════════════════════
class _LoadingState extends StatelessWidget {
const _LoadingState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
color: _accent,
strokeWidth: 2,
backgroundColor: _accent.withOpacity(0.1),
),
),
const SizedBox(height: 16),
const Text('جاري تحميل الإحصائيات...',
style: TextStyle(color: _textSecondary, fontSize: 13)),
],
),
);
}
}
// ══════════════════════════════════════════════════════════════
// CHART HELPERS (pure functions)
// ══════════════════════════════════════════════════════════════
LineChartBarData _line(
List<FlSpot> spots,
Color color, {
bool isDashed = false,
bool isStep = false,
double width = 2.5,
}) {
return LineChartBarData(
spots: spots,
isCurved: !isStep,
curveSmoothness: 0.25,
isStepLineChart: isStep,
color: color,
barWidth: width,
isStrokeCapRound: !isStep,
dotData: const FlDotData(show: false),
dashArray: isDashed ? [5, 5] : null,
lineChartStepData: const LineChartStepData(stepDirection: 0.5),
belowBarData: BarAreaData(
show: !isDashed,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [color.withOpacity(0.12), color.withOpacity(0.0)],
),
),
);
}
LineTouchData _tooltipData(StaticController c, Color accentColor) {
return LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => _surfaceElevated,
tooltipBorder: const BorderSide(color: _divider),
tooltipPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
tooltipRoundedRadius: 10,
getTooltipItems: (spots) {
return spots.map((barSpot) {
final start = c.startDate;
String dateStr = '';
if (start != null) {
final date = start.add(Duration(days: barSpot.x.toInt() - 1));
dateStr = DateFormat('d MMM', 'en').format(date);
}
return LineTooltipItem(
'$dateStr\n',
const TextStyle(
color: _textSecondary,
fontWeight: FontWeight.w600,
fontSize: 11),
children: [
TextSpan(
text: barSpot.y.toInt().toString(),
style: TextStyle(
color: barSpot.bar.color,
fontWeight: FontWeight.w900,
fontSize: 15),
),
],
);
}).toList();
},
),
);
}
FlGridData _gridData(double interval) {
return FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: interval,
getDrawingHorizontalLine: (_) =>
const FlLine(color: Color(0xFF21262D), strokeWidth: 1),
);
}
FlTitlesData _titlesData(
double interval,
double xInterval,
double days,
StaticController c,
) {
return FlTitlesData(
show: true,
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: xInterval,
getTitlesWidget: (value, _) {
if (value <= 0 || value > days) return const SizedBox.shrink();
if (c.startDate == null) return const SizedBox.shrink();
final date = c.startDate!.add(Duration(days: value.toInt() - 1));
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
DateFormat('d/M', 'en').format(date),
style: const TextStyle(
color: _textSecondary,
fontSize: 9,
fontWeight: FontWeight.w600),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: interval,
reservedSize: 36,
getTitlesWidget: (value, _) => Text(
_formatNum(value),
style: const TextStyle(color: _textSecondary, fontSize: 9),
),
),
),
);
}
double _maxY(List<FlSpot> spots) {
if (spots.isEmpty) return 10;
final max = spots.map((s) => s.y).reduce((a, b) => a > b ? a : b);
return max <= 0 ? 5 : (max * 1.2).ceilToDouble();
}
double _interval(double maxY) {
if (maxY <= 10) return 2;
if (maxY <= 50) return 10;
if (maxY <= 100) return 20;
if (maxY <= 500) return 100;
if (maxY <= 1000) return 200;
return maxY / 5;
}
double _days(StaticController c) {
if (c.startDate == null || c.endDate == null) return 30;
return c.endDate!.difference(c.startDate!).inDays + 1.0;
}
double _xInterval(double days) {
if (days > 60) return 30;
if (days > 30) return 10;
if (days > 10) return 5;
return 1;
}
String _formatNum(double v) {
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}k';
return v.toInt().toString();
}