1327 lines
49 KiB
Dart
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:sefer_admin1/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();
|
|
}
|