Files
doormile_milderapp/lib/views/Dashboard/summary/summary.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

900 lines
32 KiB
Dart

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<Summary> createState() => _SummaryState();
}
class _SummaryState extends State<Summary> {
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<String> _periods = ['Today', 'This Week', 'This Month'];
@override
void initState() {
super.initState();
controller = Get.isRegistered<SummaryController>()
? Get.find<SummaryController>()
: Get.put(SummaryController());
_rewards = Get.isRegistered<RewardsController>()
? Get.find<RewardsController>()
: Get.put(RewardsController());
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshData();
});
}
@override
void dispose() {
super.dispose();
}
Future<void> _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<double>(
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<int>(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<TotalDistanceCard> createState() => _TotalDistanceCardState();
}
class _TotalDistanceCardState extends State<TotalDistanceCard> {
final RiderWeeklyKmController _kmController = RiderWeeklyKmController();
late Future<Map<String, dynamic>> _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<Map<String, dynamic>> _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<String, dynamic> _fallbackWeeklyKms() => {
'details': const <RiderWeeklyKms>[],
'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<Map<String, dynamic>>(
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<RiderWeeklyKms>?) ??
<RiderWeeklyKms>[];
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<RiderWeeklyKms> 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,
),
),
],
);
}
}