import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/data/service_profile.dart'; import 'package:get/get.dart'; import 'package:lottie/lottie.dart' hide Marker; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/Models/summary/riderweeklykms.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:miler/controllers/summary_controller.dart'; import 'package:miler/controllers/rewards_controller.dart'; import 'package:miler/controllers/riderkm.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; class Summary extends StatefulWidget { const Summary({super.key}); @override State createState() => _SummaryState(); } class _SummaryState extends State { late final SummaryController controller; late final RewardsController _rewards; int _userId = 0; int _refreshTick = 0; int _selectedPeriod = 0; // 0=Today, 1=Week, 2=Month final List _periods = ['Today', 'This Week', 'This Month']; @override void initState() { super.initState(); controller = Get.isRegistered() ? Get.find() : Get.put(SummaryController()); _rewards = Get.isRegistered() ? Get.find() : Get.put(RewardsController()); WidgetsBinding.instance.addPostFrameCallback((_) { _refreshData(); }); } @override void dispose() { super.dispose(); } Future _refreshData() async { try { final prefs = await SharedPreferences.getInstance(); final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; if (mounted) setState(() => _userId = uid); if (uid > 0) { await controller.fetchSummaryStats(uid); _rewards.fetchBonusSummary(uid); } } catch (e) { debugPrint('Error fetching summary: $e'); } finally { if (mounted) { setState(() => _refreshTick++); } } } int get _periodCount { switch (_selectedPeriod) { case 1: return controller.week.value; case 2: return controller.month.value; default: return controller.today.value; } } int get _successRate { final total = controller.total.value; final cancelled = controller.cancelled.value; final attempts = total + cancelled; if (attempts <= 0) return 0; return ((total / attempts) * 100).round(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: ColorConstants.surface, // Earnings is no longer a tab — it opens from Account — so it needs a way // back that the four tabs never did. appBar: const MilerAppBar(title: 'Earnings', showBack: true), body: RefreshIndicator( onRefresh: _refreshData, color: ColorConstants.primary, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), slivers: [ SliverToBoxAdapter( child: Column( children: [ SizedBox(height: 16.h), _buildPeriodTabs(), SizedBox(height: 16.h), Padding( padding: EdgeInsets.symmetric(horizontal: 16.w), child: _buildPickupsHero(), ), SizedBox(height: 16.h), Padding( padding: EdgeInsets.symmetric(horizontal: 16.w), child: _buildStatsGrid(), ), SizedBox(height: 16.h), Padding( padding: EdgeInsets.symmetric(horizontal: 16.w), child: TotalDistanceCard( userId: _userId, refreshTick: _refreshTick, ), ), SizedBox(height: 32.h), ], ), ), ], ), ), ); } Widget _buildPeriodTabs() { return Container( margin: EdgeInsets.symmetric(horizontal: 16.w), height: 44.h, decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all(color: ColorConstants.outlineVariant), ), child: Row( children: List.generate(_periods.length, (index) { final isSelected = _selectedPeriod == index; return Expanded( child: GestureDetector( onTap: () { if (mounted) { setState(() { _selectedPeriod = index; }); } }, child: Container( margin: EdgeInsets.all(4.r), decoration: BoxDecoration( color: isSelected ? ColorConstants.primary : Colors.transparent, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), alignment: Alignment.center, child: Text( _periods[index], style: TextStyle( fontSize: 13.sp, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected ? Colors.white : ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ), ), ); }), ), ); } Widget _buildPickupsHero() { final periodCount = _periodCount; final total = controller.total.value; final cancelled = controller.cancelled.value; final points = _rewards.totalPoints.value; return Container( width: double.infinity, // ── Flat, like the rest of the app ── // // A two-stop gradient plus a 20px coloured shadow, on the one tab whose // neighbours are all flat fills and hairlines. Earnings was visibly from // a different product. The brand colour still owns this block — it is the // one celebratory surface in the app and keeps that — but it is a fill, // not an effect. The two faint circles behind it survive: they are what // stops a solid maroon slab reading as a error state. decoration: BoxDecoration( color: ColorConstants.primary, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: ClipRRect( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), child: Stack( children: [ Positioned( top: -34, right: -24, child: Container( width: 130.w, height: 130.w, decoration: BoxDecoration( shape: BoxShape.circle, color: ColorConstants.pureSurface.withValues(alpha: 0.07), ), ), ), Positioned( bottom: -46, left: -28, child: Container( width: 150.w, height: 150.w, decoration: BoxDecoration( shape: BoxShape.circle, color: ColorConstants.pureSurface.withValues(alpha: 0.05), ), ), ), Padding( padding: EdgeInsets.all(20.r), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( // `WEEK · PICKUPS/DELIVERIES` was a slug, not a heading — // a slash between two nouns is a database field name. The // rider is reading his own week. '${_periods[_selectedPeriod]} · ' '${ServiceProfile.active.jobNounPlural}', style: TextStyle( color: Colors.white.withValues(alpha: 0.85), fontSize: 12.5.sp, fontWeight: FontWeight.w600, letterSpacing: 0.2, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 14.h), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 320), transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: ScaleTransition( scale: Tween( begin: 0.82, end: 1.0, ).animate(anim), child: child, ), ), // 46 → 28, the scale's hero size. A 46sp figure sharing // a row with a label is the app's highest overflow risk // at 2x text, and it was 18pt larger than the largest // type anywhere else in the product. child: Text( '$periodCount', key: ValueKey(periodCount), style: MilerType.hero.on(ColorConstants.onAccent), ), ), SizedBox(width: 8.w), Padding( padding: EdgeInsets.only(bottom: 6.h), child: Text( periodCount == 1 ? 'order' : 'orders', style: MilerType.label.on( ColorConstants.onAccent.withValues(alpha: 0.75), ), ), ), ], ), SizedBox(height: 4.h), Text( '$total completed all-time', style: MilerType.label.on( ColorConstants.onAccent.withValues(alpha: 0.75), ), ), SizedBox(height: 20.h), Row( children: [ Expanded( child: _buildHeroMetric( '$_successRate%', 'Success rate', icon: LucideIcons.percent, ), ), SizedBox(width: 12.w), Expanded( child: _buildHeroMetric( '$cancelled', 'Cancelled', icon: LucideIcons.circleMinus, ), ), SizedBox(width: 12.w), Expanded( child: _buildHeroMetric( '$points', 'Reward pts', icon: LucideIcons.star, ), ), ], ), ], ), ), ], ), ), ); } /// One chip on the hero. /// /// The three used to be bare numbers over bare words — 92%, 4, 1,240 — which /// on a saturated red panel is three identical grey-white blocks the rider /// has to *read* to tell apart. A glyph makes each one findable at a glance, /// which is the whole job of a hero: the number you came for, without /// reading. Same rounded family as the cards below, at chip weight. Widget _buildHeroMetric(String value, String label, {IconData? icon}) { return Container( padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.h), decoration: BoxDecoration( color: ColorConstants.pureSurface.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (icon != null) ...[ Icon(icon, size: 16.sp, color: Colors.white.withValues(alpha: 0.9)), SizedBox(height: 6.h), ], Text( value, style: TextStyle( color: Colors.white, fontSize: 17.sp, fontWeight: FontWeight.w700, letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 3.h), Text( label, style: TextStyle( // 10 -> 12, and up from w500. A label at 65% white on maroon at // ten points is decoration; the tile is worthless without it. color: Colors.white.withValues(alpha: 0.78), fontSize: 12.sp, fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } /// ── Lifetime stops ── /// /// Three stat cards, in the same language Home uses for a trip's duration, /// distance and cash: a tinted icon disc, the figure, a small-caps caption. /// Two screens showing the same *kind* of thing — a small set of headline /// numbers — should not invent two ways to draw it, and this is the one the /// rider sees first every morning. /// /// ── What this replaced, and why twice ── /// /// First it was two floating shadowed cards with a gap. Then one bordered /// container split by a hairline, which fixed the wrong thing: it presented /// the numbers as two unrelated metrics sharing a row when they are two parts /// of one quantity. Then a proportional share bar, which showed the split /// honestly but spent a lot of height on a figure — the success rate — that is /// a single number and reads perfectly well as one. /// /// So the rate is now simply the third card. It is the figure a rider is /// judged on and the one he is proud of, and giving it equal billing with the /// counts says that more plainly than a bar underneath them did. Widget _buildStatsGrid() { final total = controller.total.value; final cancelled = controller.cancelled.value; final attempts = total + cancelled; final rate = attempts > 0 ? ((total / attempts) * 100).round() : 0; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(left: 4.w, bottom: 10.h), child: Text( 'LIFETIME', style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w700, letterSpacing: 1.0, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), // ── A ruled band, not three cards ── // // These were three shadowed white cards, each with a tinted icon disc // — the heaviest stat idiom left in the app, sitting directly under a // brand hero that already carries its own three figures. Account's // stats band set the system's shape for exactly this content: figures // separated by hairlines on one quiet fill, no discs, no shadows. // Same three facts, a quarter of the ink — and the band no longer // competes with the hero above it for the reader's first look. Container( decoration: BoxDecoration( color: ColorConstants.cardSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), padding: EdgeInsets.symmetric(vertical: 16.h), child: IntrinsicHeight( child: Row( children: [ Expanded(child: _statFigure('$total', 'Completed')), _statRule(), Expanded(child: _statFigure('$cancelled', 'Fell through')), _statRule(), Expanded( child: _statFigure( attempts > 0 ? '$rate%' : '\u2014', 'Success', ), ), ], ), ), ), ], ); } Widget _statRule() => VerticalDivider( width: 1, thickness: 1, indent: 6.h, endIndent: 6.h, color: ColorConstants.borderSubtle, ); /// One figure in the lifetime band. Monochrome on purpose: these are /// lifetime ledger facts, and the day's colour belongs to the hero above. Widget _statFigure(String value, String label) { return Column( mainAxisSize: MainAxisSize.min, children: [ FittedBox( fit: BoxFit.scaleDown, child: Text( value, style: TextStyle( fontSize: 20.sp, fontWeight: FontWeight.w700, letterSpacing: -0.6, height: 1.05, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), SizedBox(height: 4.h), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ); } } /// Weekly distance chart backed by the real `getriderweeklykms` endpoint. class TotalDistanceCard extends StatefulWidget { final int userId; final int refreshTick; const TotalDistanceCard({ super.key, required this.userId, this.refreshTick = 0, }); @override State createState() => _TotalDistanceCardState(); } class _TotalDistanceCardState extends State { final RiderWeeklyKmController _kmController = RiderWeeklyKmController(); late Future> _futureKms; int _touchedIndex = -1; @override void initState() { super.initState(); _futureKms = _fetchWeeklyKms(); } @override void didUpdateWidget(covariant TotalDistanceCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.userId != widget.userId || oldWidget.refreshTick != widget.refreshTick) { // didUpdateWidget already runs as part of a rebuild, so assign directly. // Calling setState() here re-enters the parent scroll view's viewport // mid-update and trips the `!_doingMountOrUpdate` assertion. _futureKms = _fetchWeeklyKms(); } } Future> _fetchWeeklyKms() async { if (widget.userId <= 0) { return _fallbackWeeklyKms(); } try { final res = await _kmController.getRiderWeeklyKms(widget.userId); final details = (res['details'] as List?) ?? const []; if (details.isEmpty) return _fallbackWeeklyKms(); return res; } catch (e) { debugPrint('[WEEKLY_KMS] $e'); return _fallbackWeeklyKms(); } } /// Nothing to chart. /// /// This used to return a week of seeded demo kilometres in debug builds. A /// distance chart feeds a payout, so an invented week is the one thing this /// card must not draw — and "debug only" is no protection when the person /// looking at the debug build is the one deciding whether the feature works. /// The empty state says "No distance recorded yet", which is true and is what /// makes a silent endpoint visible. Map _fallbackWeeklyKms() => { 'details': const [], 'total_kms': 0.0, }; String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(18.r), decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), boxShadow: const [ BoxShadow( color: Color(0x0A000000), blurRadius: 20, offset: Offset(0, 4), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ // `route_rounded` is a branching-path glyph — it reads as // "route planning", which is the hub's job, not as distance the // rider has already covered. A speedometer is the thing on his // handlebars that counts exactly this, and it needs no tinted // tile to be recognisable at 18sp. // `speed_rounded` is a speedometer, and this card counts // kilometres covered, not how fast he went. A route glyph is // both accurate and the one every delivery app uses for distance. Icon( LucideIcons.route, color: ColorConstants.primary, size: 20.sp, ), SizedBox(width: 9.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Weekly Distance', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 2.h), Text( 'Kilometres ridden per day', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.sp, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ], ), SizedBox(height: 20.h), FutureBuilder>( future: _futureKms, builder: (context, snapshot) { // A spinner in the space a chart is about to occupy tells the // rider nothing about what is coming and then hands over in one // frame. A block the size of the chart does both jobs. if (snapshot.connectionState == ConnectionState.waiting) { return SmoothSwap( child: MilerShimmer( key: const ValueKey('kms-loading'), child: SkeletonBone(height: 180.h, radius: 16), ), ); } final details = (snapshot.data?['details'] as List?) ?? []; final totalKms = (snapshot.data?['total_kms'] as double?) ?? 0.0; if (details.isEmpty) { return SmoothSwap( child: KeyedSubtree( key: const ValueKey('kms-empty'), child: _buildEmptyChart(), ), ); } return SmoothSwap( child: KeyedSubtree( key: const ValueKey('kms-chart'), child: _buildChart(details, totalKms), ), ); }, ), ], ), ); } Widget _buildEmptyChart() { return SizedBox( height: 190.h, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 120.w, height: 120.w, child: Lottie.asset( 'assets/lotties/no_distance.json', fit: BoxFit.contain, repeat: true, ), ), SizedBox(height: 6.h), Text( 'No distance recorded yet', style: TextStyle( fontSize: 13.sp, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ); } Widget _buildChart(List details, double totalKms) { final values = details.map((e) => e.kms).toList(); final maxVal = values.reduce((a, b) => a > b ? a : b); final safeMax = maxVal <= 0 ? 1.0 : maxVal; // Best day + averages for the summary row. int bestIdx = 0; for (int i = 1; i < values.length; i++) { if (values[i] > values[bestIdx]) bestIdx = i; } final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length; return Column( children: [ SizedBox( height: 160.h, child: BarChart( BarChartData( maxY: safeMax * 1.3, gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: safeMax / 3, getDrawingHorizontalLine: (value) => FlLine( color: ColorConstants.surfaceContainerLow, strokeWidth: 1, ), ), borderData: FlBorderData(show: false), alignment: BarChartAlignment.spaceAround, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIndex, rod, rodIndex) { return BarTooltipItem( '${rod.toY.toStringAsFixed(1)} km', TextStyle( color: Colors.white, fontWeight: FontWeight.w600, fontSize: 12.sp, fontFamily: FontConstants.fontFamily, ), ); }, ), touchCallback: (event, response) { if (!mounted) return; final int newIndex = (response != null && response.spot != null && event is! FlTapUpEvent) ? response.spot!.touchedBarGroupIndex : -1; if (newIndex != _touchedIndex) { Future.microtask(() { if (mounted) { setState(() { _touchedIndex = newIndex; }); } }); } }, ), titlesData: FlTitlesData( topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 30, getTitlesWidget: (value, _) { final idx = value.toInt(); if (idx >= 0 && idx < details.length) { final isTouched = idx == _touchedIndex; return Padding( padding: EdgeInsets.only(top: 8.h), child: Text( _shortDay(details[idx].day), style: TextStyle( fontSize: 11.sp, fontWeight: isTouched ? FontWeight.w700 : FontWeight.w500, color: isTouched ? ColorConstants.primary : ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ); } return const SizedBox.shrink(); }, ), ), ), barGroups: List.generate(values.length, (i) { final isTouched = i == _touchedIndex; return BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: values[i], // Flat, and the selected bar carries the brand while the // rest stay neutral — the comparison is the point of a // bar chart, and a gradient on every bar competes with it. color: isTouched ? ColorConstants.primary : ColorConstants.outlineVariant, width: 18.w, borderRadius: BorderRadius.vertical( top: Radius.circular(5.r), ), ), ], ); }), ), ), ), SizedBox(height: 12.h), // ── Monochrome, like every other figure row ── // // These were amber / brand-red / blue — three hues for three ledger // facts, on the one page family where colour is reserved for the hero // and the chart's own bars. And "Best Day" rendered blank when the // week's data carries no day names, leaving a caption with nothing // above it; an absent fact prints the em dash like everywhere else. Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildChartStat( 'Best day', _shortDay(details[bestIdx].day).trim().isEmpty ? '\u2014' : _shortDay(details[bestIdx].day), ColorConstants.slateText, ), _buildChartStat( 'Total', '${totalKms.toStringAsFixed(1)} km', ColorConstants.slateText, ), _buildChartStat( 'Avg/day', '${avgPerDay.toStringAsFixed(1)} km', ColorConstants.slateText, ), ], ), ], ); } Widget _buildChartStat(String label, String value, Color color) { return Column( children: [ Text( value, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w700, color: color, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 2.h), Text( label, style: TextStyle( fontSize: 10.sp, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ], ); } }