1035 lines
38 KiB
Dart
1035 lines
38 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/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<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(
|
|
// ── 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.w700 : FontWeight.w500,
|
|
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.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,
|
|
),
|
|
),
|
|
// ── 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.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(
|
|
// 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.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);
|
|
|
|
/// `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.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.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.w600,
|
|
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.w500,
|
|
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.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.
|
|
//
|
|
// ── `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.w700,
|
|
color: color,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 2.h),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 10.sp,
|
|
color: ColorConstants.onSurfaceVariant,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|