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/constants/narrative.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( // ── The same ground and the same curve as the four tabs ── // // It was `surface`, the warm off-white, on a page with no sheet at all — // so the one screen a rider opens to look at his money was the one screen // that did not belong to the app around it. The brand behind the sheet's // two top corners, the canvas under the cards, the gutter and the radii // are all the shared ones now. backgroundColor: ColorConstants.primary, // Earnings is no longer a tab — it opens from Account — so it needs a way // back that the four tabs never did. // `pageColor`: this Scaffold is brand-red, so the curve names the sheet // under it instead — see [milerBrandSurface]. appBar: const MilerAppBar( title: 'Earnings', showBack: true, pageColor: ColorConstants.pureSurface, ), body: MilerSheet( // White, like Home, Activity and the record page. It was // [Narrative.canvas] — a grey ground for panels that carry a hairline // and a lift of their own, so the grey was a second separator under // things that already had one. Four screens, one colour. color: ColorConstants.pureSurface, child: RefreshIndicator( onRefresh: _refreshData, color: ColorConstants.primary, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), slivers: [ SliverToBoxAdapter( child: Column( children: [ SizedBox(height: 14.h), _buildPeriodTabs(), SizedBox(height: Narrative.gapBlock.h), Padding( padding: EdgeInsets.symmetric( horizontal: Narrative.gutter.w, ), child: _buildPickupsHero(), ), SizedBox(height: Narrative.gapBlock.h), Padding( padding: EdgeInsets.symmetric( horizontal: Narrative.gutter.w, ), child: _buildStatsGrid(), ), SizedBox(height: Narrative.gapBlock.h), Padding( padding: EdgeInsets.symmetric( horizontal: Narrative.gutter.w, ), child: TotalDistanceCard( userId: _userId, refreshTick: _refreshTick, ), ), SizedBox(height: 32.h), ], ), ), ], ), ), ), ); } Widget _buildPeriodTabs() { // The app's segmented control, the same object as Activity's filter and // Home's trip tabs: a tonal track, one white pill, the selected label in // the brand. It was a bordered white bar with a *filled maroon* segment — // a third idiom for the one interaction, and the only place in the app // where a selected tab was a solid brand slab. return Container( margin: EdgeInsets.symmetric(horizontal: Narrative.gutter.w), height: 46.h, decoration: BoxDecoration( color: Narrative.insetDeep, borderRadius: BorderRadius.circular(Narrative.radiusPill), ), child: Row( children: List.generate(_periods.length, (index) { final isSelected = _selectedPeriod == index; return Expanded( child: GestureDetector( onTap: () { if (mounted) { setState(() { _selectedPeriod = index; }); } }, child: AnimatedContainer( duration: DesignConstants.motionState, curve: Curves.easeOutCubic, margin: EdgeInsets.all(4.r), decoration: BoxDecoration( color: isSelected ? Narrative.card : Colors.transparent, borderRadius: BorderRadius.circular(Narrative.radiusPill), boxShadow: isSelected ? Narrative.cardShadow : null, ), alignment: Alignment.center, child: Text( _periods[index], style: TextStyle( fontSize: 13.sp, fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, letterSpacing: -0.1, color: isSelected ? Narrative.burgundy : Narrative.inkMuted, 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: Narrative.burgundy, borderRadius: BorderRadius.circular(Narrative.radiusCard), // The one celebratory surface in the app, and the only place the // brand's own glow is spent. boxShadow: Narrative.burgundyGlow, ), child: ClipRRect( borderRadius: BorderRadius.circular(Narrative.radiusCard), 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.w700, 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, ), ), // ── The reward tile is withheld, not zeroed ── // // `bonuspoints` is never written — confirmed by the // backend on 24 Aug, along with the fact that // `ridercharges` holds the *client's* order price rather // than any rider payout, and that a real rate-card is a // separate build. A tile reading **0 Reward pts** on // every screen of every shift does not say "not built // yet"; it says "you have earned nothing", which is a // worse thing to tell a rider than saying nothing. // // It comes back the moment the figure is real: the tile // is drawn whenever the source is non-zero, so no code // has to change when the backend starts writing it. if (points > 0) ...[ 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.w800, 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.w700, 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.w800, 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( // A hairline and a soft lift on white, rather than a tonal fill. // The fill was carrying the separation while the page had a grey // ground to sit on; on white it was a fourth surface tone doing // what an edge does more quietly. Same recipe as every other card // in the app — see [Narrative.cardBox]. color: Narrative.card, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all(color: Narrative.cardBorder), boxShadow: Narrative.cardShadow, ), 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.w800, 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.w700, 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); /// `Mon` … `Sun`, for matching a chart row against the calendar. /// /// Written out rather than taken from `intl`: the series' own day names are /// whatever the backend sends, and the only thing being compared is the first /// three letters — so a locale-aware formatter would introduce a mismatch /// this comparison cannot have. String _weekdayName(DateTime d) => const ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d.weekday - 1]; @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.w700, 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.isEmpty ? 0.0 : values.reduce((a, b) => a > b ? a : b); // ── A week with nothing in it says so ── // // Seven bars of zero under a chart frame is not an empty state, it is a // chart that looks broken — and it looked exactly like the bug this page // had for months, where the series was never being read at all. An axis // with nothing on it should be replaced, not drawn. if (maxVal <= 0) { return Padding( padding: EdgeInsets.symmetric(vertical: 34.h), child: Column( children: [ Icon( LucideIcons.chartNoAxesColumn, size: 28.sp, color: ColorConstants.secondaryText, ), SizedBox(height: 10.h), Text( 'No distance recorded this week', textAlign: TextAlign.center, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 4.h), Text( 'Kilometres appear here once a stop is completed.', textAlign: TextAlign.center, style: TextStyle( fontSize: 12.5.sp, height: 1.4, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } final safeMax = maxVal; final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length; // ── Today, from the same series the chart is drawn from ── // // Matched on the day name rather than taken from a second endpoint: the // bars and this figure have to agree, and the only way to guarantee that is // for them to be the same number. A week whose rows carry no day names — // which happens, and is why `Best day` used to render blank — simply has no // today to point at, and the row prints the em dash. final today = _shortDay(_weekdayName(DateTime.now())).toLowerCase(); RiderWeeklyKms? todayRow; for (final d in details) { if (_shortDay(d.day).toLowerCase() == today) { todayRow = d; break; } } 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.w700, 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.w800 : FontWeight.w600, 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. // // ── `Best day` gave way to `Today` ── // // Best day named a weekday — `Wed` — which is a fact about the past // that a rider can do nothing with, and it rendered blank whenever the // week's rows carried no day names. What he opens this page to find out // is how far he has ridden *today*, and until now the app said that // nowhere: the distance was per-stop on Activity and per-week here, // with nothing in between. // // It also puts the same figure on two screens — this and the Activity // hero — which is deliberate: they are read at different moments, and // both take it from a measurement rather than from each other. Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildChartStat( 'Today', todayRow == null ? '\u2014' : '${todayRow.kms.toStringAsFixed(1)} km', 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.w800, color: color, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 2.h), Text( label, style: TextStyle( fontSize: 10.sp, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ], ); } }