Files
Siro/siro_driver/lib/views/home/statistics/earnings_page.dart
T

719 lines
29 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// earnings_page.dart — شاشة أرباح السائق
//
// ‏الترتيب مقصود ويخالف الشاشة السابقة: **اليوم أولاً**. الشاشة القديمة
// ‏كانت تبدأ بترويسة زخرفية بارتفاع ٢٠٠ ثم تبويبَي أسبوعي/شهري — والسائق
// ‏يفتح الشاشة ليعرف كم كسب اليوم، لا ليقرأ تقريراً شهرياً.
//
// ‏ثم الاقتطاعات، ثم أسبوعه، ثم تفصيل كل رحلة.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:siro_driver/constant/currency.dart';
import 'package:siro_driver/constant/finance_design_system.dart';
import 'package:siro_driver/controller/gamification/gamification_controller.dart';
import 'package:siro_driver/controller/home/statistics/earnings_controller.dart';
import 'package:siro_driver/controller/home/statistics/earnings_models.dart';
import '../../widgets/ui/siro_ui.dart';
import 'widgets/behavior_and_achievements.dart';
import 'widgets/daily_goal_widget.dart';
import 'widgets/level_progress_widget.dart';
import 'widgets/stat_summary_card.dart';
class EarningsPage extends StatelessWidget {
EarningsPage({super.key});
// ‏Get.put هنا لا في build: الشاشة السابقة كانت تنادي التحديث داخل
// ‏build، وbuild يعمل عشرات المرات — أي نداء شبكي مع كل إعادة رسم.
final EarningsController c = Get.put(EarningsController());
final GamificationController gam = Get.put(GamificationController());
@override
Widget build(BuildContext context) {
// شريط العنوان يأتي من الثيم — كان يبني ألوانه يدوياً في كل صفحة،
// فيختلف ارتفاعه ووزن خطه من شاشة لأخرى.
return Scaffold(
appBar: AppBar(title: Text('My Earnings'.tr)),
body: GetBuilder<EarningsController>(
builder: (ctl) {
if (ctl.isLoading && ctl.summary == null) {
return const SiroLoading();
}
// ‏لا بيانات ولا كاش: نعرض الخطأ صريحاً مع زر إعادة. الشاشة
// ‏السابقة كانت تعرض سبعة أيام بأصفار في هذه الحالة — فيرى
// ‏السائق دخله وقد تبخّر، وهي أسوأ رسالة يمكن أن يتلقّاها.
if (ctl.summary == null) {
return _errorState(ctl);
}
final s = ctl.summary!;
// ‏الترتيب هو القرار الأهم في هذه الشاشة بعد دمج الإحصائيات
// ‏فيها: **المال أولاً، ثم التقدّم**. السائق يفتحها ليعرف كم
// ‏كسب، لا ليرى مستواه — ووضع التلعيب قبل الأرباح كان يجعله
// ‏يمرّر ثلاث شاشات ليصل إلى ما جاء من أجله.
return RefreshIndicator(
onRefresh: () async {
await ctl.load(force: true);
await gam.fetchGamificationData(force: true);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: FinanceDesignSystem.horizontalPadding,
vertical: 8),
children: [
if (ctl.errorMessage != null) _staleBanner(ctl),
_todayCard(s, ctl),
const SizedBox(height: 16),
if (s.deductions.hasAny) ...[
_deductionsCard(s.deductions),
const SizedBox(height: 16),
],
_weekCard(s, ctl),
const SizedBox(height: 16),
_monthCard(s),
const SizedBox(height: 16),
_tripsSection(ctl),
const SizedBox(height: 24),
_progressSection(),
const SizedBox(height: 40),
],
),
);
},
),
);
}
/// ‏قسم التقدّم — ما كان شاشة «الإحصائيات» المنفصلة.
///
/// ‏في GetBuilder خاصّ به: بيانات التلعيب تصل من نقطة أخرى وفي وقت
/// ‏آخر، وربطها بمُنشئ الأرباح كان سيعيد رسم الشاشة كلها — بأرقام
/// ‏المال وقوائم الرحلات — كلما تحدّث عدّاد إنجاز.
Widget _progressSection() => GetBuilder<GamificationController>(
builder: (gc) {
if (gc.isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('تقدّمي', style: FinanceDesignSystem.headingStyle),
const SizedBox(height: 12),
// ‏هدف اليوم بعد الأرباح مباشرةً منطقياً — لكنه هنا لأنه
// ‏تحفيز لا معلومة: مكانه بعد أن يعرف السائق أرقامه.
DailyGoalWidget(controller: gc),
const SizedBox(height: 16),
LevelProgressWidget(controller: gc),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
StatSummaryCard(
icon: Icons.local_taxi_rounded,
label: 'Total Trips'.tr,
value: gc.totalTrips.toString(),
color: FinanceDesignSystem.accentBlue,
),
StatSummaryCard(
icon: Icons.star_rounded,
label: 'Rating'.tr,
value: gc.averageRating.toStringAsFixed(1),
color: const Color(0xFFFFD700),
),
StatSummaryCard(
icon: Icons.whatshot_rounded,
label: 'Day Streak'.tr,
value: '${gc.consecutiveDays}',
color: const Color(0xFFFF5722),
),
StatSummaryCard(
icon: Icons.people_rounded,
label: 'Referrals'.tr,
value: gc.totalReferrals.toString(),
color: const Color(0xFF9C27B0),
),
],
),
const SizedBox(height: 16),
BehaviorSection(gc: gc),
const SizedBox(height: 24),
AchievementsSection(gc: gc),
],
);
},
);
Widget _errorState(EarningsController ctl) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off_rounded,
size: 48, color: FinanceDesignSystem.textMuted),
const SizedBox(height: 12),
Text(
ctl.errorMessage ?? 'تعذّر تحميل أرباحك',
textAlign: TextAlign.center,
style: TextStyle(
color: FinanceDesignSystem.primaryDark, fontSize: 15),
),
const SizedBox(height: 6),
Text(
'أرباحك محفوظة ولم يضع منها شيء — المشكلة في التحميل فقط.',
textAlign: TextAlign.center,
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 12),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () => ctl.load(force: true),
icon: const Icon(Icons.refresh_rounded),
label: const Text('إعادة المحاولة'),
),
],
),
),
);
/// ‏بيانات قديمة معروضة بعد فشل التحديث. إظهار ذلك صراحةً أصدق من
/// ‏تقديم رقمٍ عمره ساعات على أنه لحظته.
Widget _staleBanner(EarningsController ctl) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius),
),
child: Row(
children: [
const Icon(Icons.history_rounded, color: Colors.orange, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'${ctl.errorMessage} — هذه أرقام ${ctl.lastUpdatedLabel}',
style: const TextStyle(color: Colors.orange, fontSize: 12),
),
),
TextButton(
onPressed: () => ctl.load(force: true),
child: const Text('تحديث', style: TextStyle(fontSize: 12)),
),
],
),
);
/// ‏بطاقة اليوم — الرقم الذي فُتحت الشاشة من أجله.
Widget _todayCard(EarningsSummary s, EarningsController ctl) {
final t = s.today;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: FinanceDesignSystem.balanceGradient,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
boxShadow: [FinanceDesignSystem.softShadow],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('أرباح اليوم',
style: TextStyle(color: Colors.white70, fontSize: 13)),
const Spacer(),
Text(ctl.lastUpdatedLabel,
style: const TextStyle(color: Colors.white54, fontSize: 11)),
],
),
const SizedBox(height: 6),
Text('${t.net.toStringAsFixed(2)} ${CurrencyHelper.currency}',
style: const TextStyle(
color: Colors.white,
fontSize: 34,
fontWeight: FontWeight.bold)),
const SizedBox(height: 14),
Row(
children: [
_miniStat(Icons.local_taxi_rounded, '${t.trips}', 'رحلة'),
_miniStat(Icons.schedule_rounded,
t.hours.toStringAsFixed(1), 'ساعة'),
_miniStat(Icons.route_rounded,
t.distance.toStringAsFixed(0), 'كم'),
],
),
// ‏تركيبة المبلغ ظاهرة: إجمالي ← عمولة ← صافي. السائق الذي يرى
// ‏التركيبة يفهم، والذي يرى الصافي وحده يخمّن ويشكّ.
if (t.gross > 0) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_breakdownBit('دفع الركاب', t.gross),
_breakdownBit('عمولة المنصة', t.commission, dim: true),
_breakdownBit('لك', t.net, bold: true),
],
),
),
],
],
),
);
}
Widget _miniStat(IconData icon, String value, String label) => Expanded(
child: Row(
children: [
Icon(icon, color: Colors.white70, size: 16),
const SizedBox(width: 6),
Text(value,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700)),
const SizedBox(width: 3),
Text(label,
style: const TextStyle(color: Colors.white54, fontSize: 11)),
],
),
);
Widget _breakdownBit(String label, double value,
{bool dim = false, bool bold = false}) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6), fontSize: 10)),
const SizedBox(height: 2),
Text(value.toStringAsFixed(2),
style: TextStyle(
color: dim
? Colors.white.withValues(alpha: 0.7)
: Colors.white,
fontSize: bold ? 15 : 13,
fontWeight: bold ? FontWeight.bold : FontWeight.w500)),
],
);
/// ‏الاقتطاعات بجانب الأرباح التي اقتُطعت منها. عرضها في شاشة المنتج
/// ‏وحدها كان سيجعل الخصم مفاجأةً — وهي أخطر ما يصيب منتجاً ائتمانياً.
Widget _deductionsCard(Deductions d) => Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.account_balance_wallet_rounded,
size: 18, color: FinanceDesignSystem.accentBlue),
const SizedBox(width: 8),
Text('الاقتطاعات', style: FinanceDesignSystem.headingStyle),
const Spacer(),
if (d.today > 0)
Text('اليوم ${d.today.toStringAsFixed(2)}',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 12)),
],
),
const SizedBox(height: 10),
...d.items.map((i) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(i.name,
style: TextStyle(
color: FinanceDesignSystem.primaryDark,
fontSize: 13))),
Text(
'متبقٍّ ${i.outstanding.toStringAsFixed(2)} ${CurrencyHelper.currency}',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 12)),
],
),
)),
if (d.outstanding > 0) ...[
const SizedBox(height: 6),
Text(
'يُقتطع تدريجياً من أرباحك بسقف يومي، ولا يُخصم منك في يوم بلا دخل.',
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 11),
),
],
],
),
);
Widget _weekCard(EarningsSummary s, EarningsController ctl) {
final maxNet = s.daily.fold<double>(
0, (m, d) => d.net > m ? d.net : m);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('آخر ٧ أيام', style: FinanceDesignSystem.headingStyle),
const Spacer(),
if (s.trendPercent != null) _trendChip(s.trendPercent!),
],
),
const SizedBox(height: 4),
Text(
'${s.week.net.toStringAsFixed(2)} ${CurrencyHelper.currency} · ${s.week.trips} رحلة',
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 13)),
const SizedBox(height: 16),
SizedBox(
height: 110,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: s.daily.map((d) {
final selected = ctl.selectedDay == d.day;
final ratio = maxNet > 0 ? d.net / maxNet : 0.0;
return Expanded(
child: GestureDetector(
// ‏نقر العمود يعرض رحلات ذلك اليوم. الرسم البياني الذي
// ‏لا يُنقر عليه زينة؛ والسائق يسأل عن يومٍ بعينه.
onTap: () => ctl.loadTrips(day: selected ? null : d.day),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(d.net > 0 ? d.net.toStringAsFixed(0) : '',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 9)),
const SizedBox(height: 4),
Container(
height: 60 * (ratio == 0 ? 0.03 : ratio),
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: selected
? FinanceDesignSystem.accentBlue
: FinanceDesignSystem.accentBlue
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 6),
Text(_dayLabel(d.day),
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 10)),
],
),
),
);
}).toList(),
),
),
],
),
);
}
/// ‏الشهر — يحلّ محل الرسم الشهري في الشاشة القديمة.
///
/// ‏أرقام لا رسم بياني: الرسم الشهري كان ثلاثين عموداً ضيّقاً لا يُقرأ
/// ‏منها شيء على شاشة هاتف. السائق يسأل عن مجموع شهره ومعدّله اليومي،
/// ‏لا عن شكل توزيعه.
Widget _monthCard(EarningsSummary s) {
final m = s.month;
final avgPerDay = DateTime.now().day > 0 ? m.net / DateTime.now().day : 0.0;
final perHour = m.hours > 0 ? m.net / m.hours : 0.0;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.cardRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('هذا الشهر', style: FinanceDesignSystem.headingStyle),
const SizedBox(height: 12),
Row(
children: [
_monthStat('الصافي', m.net.toStringAsFixed(0)),
_monthStat('رحلة', '${m.trips}'),
_monthStat('ساعة', m.hours.toStringAsFixed(0)),
],
),
const SizedBox(height: 12),
Row(
children: [
// ‏معدّل الساعة هو الرقم الذي يقرّر به السائق متى يعمل —
// ‏وأنفع من أي مجموع، لأن المجموع يكافئ الساعات لا الكفاءة.
_monthStat('معدّل الساعة', perHour.toStringAsFixed(1)),
_monthStat('معدّل اليوم', avgPerDay.toStringAsFixed(1)),
_monthStat('العمولة', m.commission.toStringAsFixed(0)),
],
),
],
),
);
}
Widget _monthStat(String label, String value) => Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value,
style: TextStyle(
color: FinanceDesignSystem.primaryDark,
fontSize: 17,
fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text(label,
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 11)),
],
),
);
Widget _trendChip(double trend) {
final up = trend >= 0;
final color =
up ? FinanceDesignSystem.successGreen : FinanceDesignSystem.dangerRed;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(up ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 14, color: color),
const SizedBox(width: 4),
// ‏المقارنة بأسبوعه هو لا بالسائقين الآخرين.
Text('${trend.abs().toStringAsFixed(0)}٪ عن الأسبوع الماضي',
style: TextStyle(color: color, fontSize: 11)),
],
),
);
}
String _dayLabel(String iso) {
final d = DateTime.tryParse(iso);
if (d == null) return '';
const names = ['اثن', 'ثلا', 'أرب', 'خمي', 'جمع', 'سبت', 'أحد'];
return names[d.weekday - 1];
}
Widget _tripsSection(EarningsController ctl) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
ctl.selectedDay == null
? 'رحلات الأسبوع'
: 'رحلات ${ctl.selectedDay}',
style: FinanceDesignSystem.headingStyle),
const Spacer(),
if (ctl.selectedDay != null)
TextButton(
onPressed: () => ctl.loadTrips(),
child: const Text('كل الأسبوع',
style: TextStyle(fontSize: 12))),
],
),
const SizedBox(height: 8),
if (ctl.isLoadingTrips)
const Padding(
padding: EdgeInsets.all(20),
child: Center(child: CircularProgressIndicator()),
)
else if (ctl.trips.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Center(
child: Text('لا رحلات في هذه الفترة',
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: 13)),
),
)
else
...ctl.trips.map(_tripTile),
],
);
/// ‏تفصيل الرحلة الواحدة — الإجابة على السؤال الأول عند كل سائق:
/// ‏«لماذا أخذت هذا المبلغ من هذه الرحلة؟»
Widget _tripTile(TripEarning t) {
final b = t.breakdown;
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: FinanceDesignSystem.cardColor,
borderRadius: BorderRadius.circular(FinanceDesignSystem.mainRadius),
border: Border.all(color: FinanceDesignSystem.borderColor),
),
child: Theme(
data: ThemeData(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
title: Row(
children: [
Expanded(
child: Text(
'${t.from} ← ${t.to}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: FinanceDesignSystem.primaryDark,
fontSize: 13,
fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
Text('${b.net.toStringAsFixed(2)} ${CurrencyHelper.currency}',
style: TextStyle(
color: FinanceDesignSystem.successGreen,
fontWeight: FontWeight.bold,
fontSize: 14)),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Text(
'${_timeLabel(t.at)} · ${t.distanceKm.toStringAsFixed(1)} كم · ${t.minutes} د',
style: TextStyle(
color: FinanceDesignSystem.textSecondary, fontSize: 11),
),
if (b.zeroCommission) ...[
const SizedBox(width: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: FinanceDesignSystem.successGreen
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text('عمولة معفاة',
style: TextStyle(
color: FinanceDesignSystem.successGreen,
fontSize: 9)),
),
],
],
),
),
children: [
_row('ما دفعه الراكب', b.gross),
if (b.bonus > 0) _row('مكافأة', b.bonus, positive: true),
_row('عمولة المنصة', b.commission, negative: true),
Divider(color: FinanceDesignSystem.borderColor, height: 16),
_row('صافي لك', b.net, bold: true),
const SizedBox(height: 6),
Row(
children: [
Icon(
t.paymentMethod.toLowerCase().contains('cash')
? Icons.money_rounded
: Icons.credit_card_rounded,
size: 13,
color: FinanceDesignSystem.textMuted),
const SizedBox(width: 5),
Text(t.paymentMethod,
style: TextStyle(
color: FinanceDesignSystem.textMuted, fontSize: 11)),
const Spacer(),
Text('#${t.rideId}',
style: TextStyle(
color: FinanceDesignSystem.textMuted, fontSize: 11)),
],
),
],
),
),
);
}
Widget _row(String label, double value,
{bool bold = false, bool negative = false, bool positive = false}) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(label,
style: TextStyle(
color: FinanceDesignSystem.textSecondary,
fontSize: bold ? 13 : 12)),
),
Text(
'${negative ? '− ' : positive ? '+ ' : ''}${value.toStringAsFixed(2)}',
style: TextStyle(
color: negative
? FinanceDesignSystem.dangerRed
: positive
? FinanceDesignSystem.successGreen
: FinanceDesignSystem.primaryDark,
fontSize: bold ? 14 : 12,
fontWeight: bold ? FontWeight.bold : FontWeight.w500,
),
),
],
),
);
String _timeLabel(String at) {
final d = DateTime.tryParse(at);
if (d == null) return '';
final h = d.hour.toString().padLeft(2, '0');
final m = d.minute.toString().padLeft(2, '0');
return '$h:$m';
}
}