production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -8,6 +8,7 @@ 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';
@@ -92,59 +93,89 @@ class _SummaryState extends State<Summary> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.surface,
// ── 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.
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),
],
),
// `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: 16.w),
height: 44.h,
margin: EdgeInsets.symmetric(horizontal: Narrative.gutter.w),
height: 46.h,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.outlineVariant),
color: Narrative.insetDeep,
borderRadius: BorderRadius.circular(Narrative.radiusPill),
),
child: Row(
children: List.generate(_periods.length, (index) {
@@ -158,23 +189,23 @@ class _SummaryState extends State<Summary> {
});
}
},
child: Container(
child: AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
margin: EdgeInsets.all(4.r),
decoration: BoxDecoration(
color: isSelected
? ColorConstants.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
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.w600 : FontWeight.w500,
color: isSelected
? Colors.white
: ColorConstants.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: -0.1,
color: isSelected ? Narrative.burgundy : Narrative.inkMuted,
fontFamily: FontConstants.fontFamily,
),
),
@@ -203,11 +234,14 @@ class _SummaryState extends State<Summary> {
// 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),
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(DesignConstants.radiusXl),
borderRadius: BorderRadius.circular(Narrative.radiusCard),
child: Stack(
children: [
Positioned(
@@ -317,14 +351,30 @@ class _SummaryState extends State<Summary> {
icon: LucideIcons.circleMinus,
),
),
SizedBox(width: 12.w),
Expanded(
child: _buildHeroMetric(
'$points',
'Reward pts',
icon: LucideIcons.star,
// ── 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,
),
),
),
],
],
),
],
@@ -437,8 +487,15 @@ class _SummaryState extends State<Summary> {
// competes with the hero above it for the reader's first look.
Container(
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
// 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(
@@ -575,6 +632,15 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
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(
@@ -713,16 +779,74 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
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;
final maxVal = values.isEmpty
? 0.0
: values.reduce((a, b) => a > b ? a : b);
// 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;
// ── 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(
@@ -843,17 +967,28 @@ class _TotalDistanceCardState extends State<TotalDistanceCard> {
//
// 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.
// 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(
'Best day',
_shortDay(details[bestIdx].day).trim().isEmpty
'Today',
todayRow == null
? '\u2014'
: _shortDay(details[bestIdx].day),
: '${todayRow.kms.toStringAsFixed(1)} km',
ColorConstants.slateText,
),
_buildChartStat(