Files
Siro/siro_admin/lib/views/admin/static/static.dart
T

1291 lines
46 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';
class StaticDash extends StatelessWidget {
const StaticDash({super.key});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: cs.surface,
body: GetBuilder<StaticController>(
builder: (c) {
return CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
_SliverHeader(controller: c, cs: cs, isDark: isDark),
if (c.isLoading)
SliverFillRemaining(child: _LoadingState(cs: cs))
else ...[
SliverToBoxAdapter(
child: _DateBadge(controller: c, cs: cs),
),
SliverToBoxAdapter(
child: _KpiRow(controller: c, cs: cs),
),
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: [
_ChartCard(
title: 'الركاب',
total: c.totalMonthlyPassengers,
spots: c.chartDataPassengers,
compareSpots:
c.isComparing ? c.chartDataPassengersCompare : null,
color: cs.tertiary,
icon: Icons.groups_rounded,
controller: c,
cs: cs,
),
const SizedBox(height: 12),
_ChartCard(
title: 'السائقون',
total: c.totalMonthlyDrivers,
spots: c.chartDataDrivers,
compareSpots:
c.isComparing ? c.chartDataDriversCompare : null,
color: const Color(0xFFF59E0B),
icon: Icons.drive_eta_rounded,
controller: c,
cs: cs,
),
const SizedBox(height: 12),
_ChartCard(
title: 'الرحلات',
total: c.totalMonthlyRides,
spots: c.chartDataRides,
compareSpots:
c.isComparing ? c.chartDataRidesCompare : null,
color: cs.primary,
icon: Icons.map_rounded,
controller: c,
cs: cs,
),
const SizedBox(height: 24),
],
),
),
),
),
const SliverToBoxAdapter(
child: _SectionLabel('أداء فريق العمل', Icons.workspaces_filled),
),
SliverToBoxAdapter(
child: Column(
children: [
_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,
cs: cs,
),
const SizedBox(height: 12),
_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()),
cs: cs,
),
const SizedBox(height: 24),
],
),
),
if (c.employmentStatsList.isNotEmpty) ...[
const SliverToBoxAdapter(
child: _SectionLabel('لوحة الصدارة', Icons.emoji_events_rounded),
),
SliverToBoxAdapter(
child: _EmployeeLeaderboard(stats: c.employmentStatsList, cs: cs),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
const SliverToBoxAdapter(
child: _SectionLabel('متابعة التسجيل', Icons.assignment_turned_in_rounded),
),
SliverToBoxAdapter(
child: Column(
children: [
_ChartCard(
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: const Color(0xFF10B981),
icon: Icons.phone_in_talk_rounded,
controller: c,
cs: cs,
),
const SizedBox(height: 60),
],
),
),
],
],
);
},
),
);
}
}
// ─── SLIVER HEADER ───────────────────────────────────────────────────────────
class _SliverHeader extends StatelessWidget {
final StaticController controller;
final ColorScheme cs;
final bool isDark;
const _SliverHeader({required this.controller, required this.cs, required this.isDark});
@override
Widget build(BuildContext context) {
return SliverAppBar(
expandedHeight: 100,
pinned: true,
floating: true,
backgroundColor: cs.surface,
elevation: 0,
leading: GestureDetector(
onTap: () => Get.back(),
child: Container(
margin: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.outline),
),
child: Icon(Icons.arrow_back_ios_new_rounded,
color: cs.onSurfaceVariant, size: 16),
),
),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [cs.primary.withValues(alpha: 0.06), cs.surface]
: [cs.primary.withValues(alpha: 0.04), cs.surface],
),
),
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: cs.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.primary.withValues(alpha: 0.25)),
),
child: Icon(Icons.bar_chart_rounded,
color: cs.primary, size: 18),
),
const SizedBox(width: 10),
ShaderMask(
shaderCallback: (b) => LinearGradient(
colors: [cs.primary, cs.tertiary],
).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: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: cs.outline),
),
child: Icon(Icons.refresh_rounded,
color: cs.onSurfaceVariant, size: 18),
),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(height: 1, color: cs.outline),
),
);
}
}
// ─── DATE BADGE ──────────────────────────────────────────────────────────────
class _DateBadge extends StatelessWidget {
final StaticController controller;
final ColorScheme cs;
const _DateBadge({required this.controller, required this.cs});
@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: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today_rounded,
color: cs.primary, size: 13),
const SizedBox(width: 7),
Text(
controller.currentDateString,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
if (controller.isComparing) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.onSurfaceVariant,
),
),
const SizedBox(width: 7),
Text(
controller.compareDateString,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500),
),
],
),
),
]
],
),
);
}
}
// ─── KPI ROW ─────────────────────────────────────────────────────────────────
class _KpiRow extends StatelessWidget {
final StaticController controller;
final ColorScheme cs;
const _KpiRow({required this.controller, required this.cs});
@override
Widget build(BuildContext context) {
final items = [
_KpiItem('الركاب', controller.totalMonthlyPassengers,
Icons.groups_rounded, cs.tertiary),
_KpiItem('السائقون', controller.totalMonthlyDrivers,
Icons.drive_eta_rounded, const Color(0xFFF59E0B)),
_KpiItem(
'الرحلات', controller.totalMonthlyRides, Icons.map_rounded, cs.primary),
];
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: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: item.color.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(item.icon, color: item.color, size: 18),
const SizedBox(height: 8),
Text(
item.value,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
height: 1,
),
),
const SizedBox(height: 3),
Text(item.label,
style: TextStyle(color: cs.onSurfaceVariant, 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) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 12),
child: Row(
children: [
Container(
width: 3,
height: 16,
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 10),
Icon(icon, size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 8),
Text(
text,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
],
),
);
}
}
// ─── CHART CARD (Theme-aware, premium) ───────────────────────────────────────
class _ChartCard extends StatelessWidget {
final String title, total;
final List<FlSpot> spots;
final List<FlSpot>? compareSpots;
final Color color;
final IconData icon;
final StaticController controller;
final ColorScheme cs;
const _ChartCard({
required this.title,
required this.total,
required this.spots,
this.compareSpots,
required this.color,
required this.icon,
required this.controller,
required this.cs,
});
@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: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.15)),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(18),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.25)),
),
child: Icon(icon, color: color, size: 18),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700)),
Text('الإجمالي: $total',
style: TextStyle(
color: cs.onSurfaceVariant, fontSize: 11)),
]),
]),
if (controller.isComparing)
Row(children: [
Container(
width: 16,
height: 2,
decoration: BoxDecoration(
color: cs.onSurfaceVariant,
borderRadius: BorderRadius.circular(1),
)),
const SizedBox(width: 5),
Text('سابق',
style: TextStyle(
color: cs.onSurfaceVariant, fontSize: 10)),
]),
],
),
),
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, cs),
gridData: _gridData(interval, cs),
titlesData: _titlesData(interval, xInterval, days, controller, cs),
borderData: FlBorderData(show: false),
lineBarsData: [
if (compareSpots != null && compareSpots!.isNotEmpty)
_line(compareSpots!, cs.onSurfaceVariant,
isDashed: true, isStep: true, width: 1.5, cs: cs),
_line(spots, color, isStep: true, width: 2.5, cs: cs),
],
)),
),
),
),
_ControlBar(controller: controller, accentColor: color, cs: cs),
],
),
),
);
}
}
// ─── DYNAMIC MULTI-LINE CHART CARD ──────────────────────────────────────────
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;
final ColorScheme cs;
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,
required this.cs,
});
@override
Widget build(BuildContext context) {
final employees = controller.employeeData.values.toList();
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: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline),
boxShadow: [
BoxShadow(
color: cs.shadow.withValues(alpha: 0.08),
blurRadius: 15,
offset: const Offset(0, 6),
),
],
),
child: Column(
children: [
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: cs.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: cs.primary.withValues(alpha: 0.20)),
),
child: Icon(icon, color: cs.primary, size: 18),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700)),
Text(subtitle,
style: TextStyle(
color: cs.onSurfaceVariant, fontSize: 11)),
if (onTap != null)
Text(
'اضغط لعرض التفاصيل اليومية',
style: TextStyle(
color: cs.primary,
fontSize: 9,
fontWeight: FontWeight.w600),
),
]),
]),
if (onTap != null)
Icon(Icons.arrow_forward_ios_rounded,
size: 14, color: cs.onSurfaceVariant),
],
),
),
),
if (employees.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Text('لا توجد بيانات',
style: TextStyle(color: cs.onSurfaceVariant)),
)
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),
cs: cs,
);
}).toList(),
),
),
const SizedBox(height: 8),
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, cs.primary, cs),
gridData: _gridData(interval, cs),
titlesData: _titlesData(interval, xInterval, days, controller, cs),
borderData: FlBorderData(show: false),
lineBarsData: [
if (controller.isComparing)
...employees.map((emp) => _line(
getCompareSpots(emp),
emp.color.withValues(alpha: 0.25),
isDashed: true,
isStep: false,
width: 1.5,
cs: cs,
)),
...employees.map((emp) => _line(
getSpots(emp),
emp.color,
isStep: false,
width: 2.5,
cs: cs,
)),
],
)),
),
),
),
],
_ControlBar(controller: controller, accentColor: cs.primary, cs: cs),
],
),
),
);
}
}
// ─── LEGEND CHIP ─────────────────────────────────────────────────────────────
class _LegendChip extends StatelessWidget {
final String name;
final Color color;
final int total;
final ColorScheme cs;
const _LegendChip(
{required this.name, required this.color, required this.total, required this.cs});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 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: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 10,
fontWeight: FontWeight.w500)),
],
),
);
}
}
// ─── EMPLOYEE LEADERBOARD ───────────────────────────────────────────────────
class _EmployeeLeaderboard extends StatelessWidget {
final List<EmploymentStat> stats;
final ColorScheme cs;
const _EmployeeLeaderboard({required this.stats, required this.cs});
@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: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: cs.outline),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFF59E0B).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: const Color(0xFFF59E0B).withValues(alpha: 0.25)),
),
child: const Icon(Icons.emoji_events_rounded,
color: Color(0xFFF59E0B), size: 16),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('لوحة الصدارة',
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700)),
Text('إجمالي الإدخالات حسب الموظف',
style: TextStyle(
color: cs.onSurfaceVariant, fontSize: 10)),
],
),
]),
Text(
'${stats.fold(0, (s, e) => s + e.count)} إجمالي',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 20),
...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: [
_RankBadge(rank: i + 1, color: stat.color, cs: cs),
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: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w800,
fontFamily: 'monospace',
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: pct.toDouble(),
backgroundColor: cs.outline,
valueColor:
AlwaysStoppedAnimation(stat.color),
minHeight: 5,
),
),
],
),
),
],
),
],
),
);
}),
],
),
),
);
}
}
class _RankBadge extends StatelessWidget {
final int rank;
final Color color;
final ColorScheme cs;
const _RankBadge({required this.rank, required this.color, required this.cs});
@override
Widget build(BuildContext context) {
final isTop3 = rank <= 3;
final medalColors = [
const Color(0xFFF59E0B),
cs.onSurfaceVariant,
const Color(0xFFCD7F32)
];
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isTop3
? medalColors[rank - 1].withValues(alpha: 0.12)
: cs.outline,
shape: BoxShape.circle,
border: Border.all(
color: isTop3
? medalColors[rank - 1].withValues(alpha: 0.4)
: Colors.transparent),
),
child: Center(
child: Text(
'$rank',
style: TextStyle(
color: isTop3 ? medalColors[rank - 1] : cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
);
}
}
// ─── CONTROL BAR ─────────────────────────────────────────────────────────────
class _ControlBar extends StatelessWidget {
final StaticController controller;
final Color accentColor;
final ColorScheme cs;
const _ControlBar(
{required this.controller, required this.accentColor, required this.cs});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: cs.surface,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)),
border: Border(top: BorderSide(color: cs.outline)),
),
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: Theme.of(ctx).colorScheme.copyWith(
primary: accentColor,
surface: cs.surfaceContainerHighest,
),
),
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: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(9),
border: Border.all(color: cs.outline),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.calendar_today_rounded,
size: 13, color: accentColor),
const SizedBox(width: 7),
Text(
_formatRange(controller),
style: TextStyle(
color: cs.onSurfaceVariant,
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
? cs.error.withValues(alpha: 0.10)
: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: controller.isComparing
? cs.error.withValues(alpha: 0.3)
: cs.outline),
),
child: Row(
children: [
Icon(
controller.isComparing
? Icons.visibility_off_outlined
: Icons.compare_arrows_rounded,
size: 14,
color: controller.isComparing ? cs.error : accentColor,
),
const SizedBox(width: 5),
Text(
controller.isComparing ? 'إلغاء' : 'مقارنة',
style: TextStyle(
color: controller.isComparing ? cs.error : 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 {
final ColorScheme cs;
const _LoadingState({required this.cs});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
color: cs.primary,
strokeWidth: 2,
backgroundColor: cs.primary.withValues(alpha: 0.1),
),
),
const SizedBox(height: 16),
Text('جاري تحميل الإحصائيات...',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
],
),
);
}
}
// ─── CHART HELPERS (World-class premium) ─────────────────────────────────────
LineChartBarData _line(
List<FlSpot> spots,
Color color, {
bool isDashed = false,
bool isStep = false,
double width = 2.5,
required ColorScheme cs,
}) {
return LineChartBarData(
spots: spots,
isCurved: !isStep,
curveSmoothness: 0.4,
isStepLineChart: isStep,
color: color,
barWidth: width + 1,
isStrokeCapRound: !isStep,
shadow: Shadow(
color: color.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 4)),
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.withValues(alpha: 0.35),
color.withValues(alpha: 0.0),
],
),
),
);
}
LineTouchData _tooltipData(
StaticController c, Color accentColor, ColorScheme cs) {
return LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => cs.surfaceContainerHighest,
tooltipBorder: BorderSide(color: cs.outline),
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',
TextStyle(
color: cs.onSurfaceVariant,
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, ColorScheme cs) {
return FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: interval,
getDrawingHorizontalLine: (_) => FlLine(
color: cs.outline.withValues(alpha: 0.5), strokeWidth: 1),
);
}
FlTitlesData _titlesData(
double interval,
double xInterval,
double days,
StaticController c,
ColorScheme cs,
) {
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: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 9,
fontWeight: FontWeight.w600),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: interval,
reservedSize: 36,
getTitlesWidget: (value, _) => Text(
_formatNum(value),
style: TextStyle(color: cs.onSurfaceVariant, 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();
}