1302 lines
46 KiB
Dart
1302 lines
46 KiB
Dart
import 'package:siro_admin/constant/theme.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: cs.warning,
|
|
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: cs.success,
|
|
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) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
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) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
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 cs = Theme.of(context).colorScheme;
|
|
final items = [
|
|
_KpiItem('الركاب', controller.totalMonthlyPassengers,
|
|
Icons.groups_rounded, cs.tertiary),
|
|
_KpiItem('السائقون', controller.totalMonthlyDrivers,
|
|
Icons.drive_eta_rounded, cs.warning),
|
|
_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 cs = Theme.of(context).colorScheme;
|
|
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 cs = Theme.of(context).colorScheme;
|
|
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) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
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 cs = Theme.of(context).colorScheme;
|
|
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: cs.warning.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(9),
|
|
border: Border.all(
|
|
color: cs.warning.withValues(alpha: 0.25)),
|
|
),
|
|
child: Icon(Icons.emoji_events_rounded,
|
|
color: cs.warning, 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 cs = Theme.of(context).colorScheme;
|
|
final isTop3 = rank <= 3;
|
|
final medalColors = [
|
|
cs.warning,
|
|
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) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
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) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
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();
|
|
}
|