Files
doormile_milderapp/lib/views/Dashboard/home/trip_tabs.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

753 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP TABS — Trip 1 · Trip 2 · Trip 3
///
/// A rider is assigned at most three trips a day, so three tabs fit across a
/// phone and every trip is one tap away. Tabs beat a dropdown here for a
/// reason: with a dropdown the rider cannot see how many trips he has or how
/// far along each one is without opening it, and "how much is left today" is
/// the question he asks most.
///
/// Each tab carries its own completion percentage, so the whole day reads at a
/// glance: `Trip 1 · 100% Trip 2 · 40% Trip 3 · 0%`
///
/// **Overflow.** Three is the norm, not a guarantee — a hub can assign more.
/// Past three the bar keeps the first two tabs and folds the rest into a
/// "More" menu, rather than shrinking every tab until the labels are
/// unreadable. The dropdown is the exception path, exactly as asked.
/// ─────────────────────────────────────────────────────────────────────────
class TripTabs extends StatefulWidget {
final List<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
/// Optimistic accept/reject sets, for the per-tab completion figure.
final Set<String> acceptedIds;
final Set<String> rejectedIds;
/// Tabs shown inline before the rest collapse into the overflow menu.
///
/// Also the number of tabs ALWAYS rendered: a rider's day is three trips, so
/// Trip 2 and Trip 3 are shown even before the hub has assigned them. A tab
/// that appears and disappears through the morning makes the screen feel
/// unstable; an empty Trip 3 that says "nothing assigned yet" answers the
/// question he actually has.
static const int maxInlineTabs = 3;
/// The travelling selection marker at the foot of the pill.
static const Key markerKey = ValueKey('trip_tab_marker');
const TripTabs({
super.key,
required this.trips,
required this.selectedIndex,
required this.onSelect,
this.acceptedIds = const {},
this.rejectedIds = const {},
});
@override
State<TripTabs> createState() => _TripTabsState();
}
class _TripTabsState extends State<TripTabs>
with SingleTickerProviderStateMixin {
/// Carries the pill from the tab it was on to the tab just chosen.
///
/// Same mechanism as the bottom nav bar, and for the same reason. The pill
/// used to belong to each tab — an `AnimatedContainer` per segment whose fill
/// went from transparent to white — so moving from Trip 3 to Trip 1 faded one
/// pill out on the right and a *different* one in on the left. Nothing ever
/// occupied the space between, so there was no object to travel across Trip 2
/// with, and the marker appeared to teleport.
///
/// There is one pill now, owned by the control, and this is its position in
/// *slot* units — 1.5 is genuinely halfway between the second and third
/// segment. It also drives the tint on every label, so a tab warms up as the
/// pill passes over it instead of switching the instant it is tapped while the
/// pill is still two segments away.
///
/// Initialised on the declaration rather than in `initState`: a `late final`
/// assigned in `initState` is never filled in on a `State` that already
/// exists, and hot reload reuses the existing `State`. See the identical note
/// in `Bottom_page.dart`.
late final AnimationController _pillAnim = AnimationController(
vsync: this,
// Matches the nav bar. A pill crossing two segments needs long enough for
// the eye to follow it; the 200ms the cross-fade used reads as a jump.
duration: const Duration(milliseconds: 380),
value: 1.0,
);
late final Animation<double> _pillCurve = CurvedAnimation(
parent: _pillAnim,
curve: Curves.easeInOutCubic,
);
late Tween<double> _pillTween = Tween<double>(
begin: _slotOf(_inlineTabs()),
end: _slotOf(_inlineTabs()),
);
double get _pillPos => _pillTween.evaluate(_pillCurve);
/// Trip indices rendered as inline segments, left to right.
///
/// Three is the norm, not a guarantee — a hub can assign more. Past three the
/// bar keeps the first two segments and folds the rest into a "More" menu
/// rather than shrinking every tab until the labels are unreadable. When the
/// selection lives in the overflow it is pulled into the last inline slot, so
/// the rider can always see which trip he is looking at.
List<int> _inlineTabs() {
final tabCount = widget.trips.length > TripTabs.maxInlineTabs
? widget.trips.length
: TripTabs.maxInlineTabs;
final overflows = tabCount > TripTabs.maxInlineTabs;
final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount;
return <int>[
for (var i = 0; i < inlineCount; i++) i,
if (overflows && widget.selectedIndex >= inlineCount)
widget.selectedIndex,
];
}
List<int> _hiddenTabs(List<int> inline) {
final tabCount = widget.trips.length > TripTabs.maxInlineTabs
? widget.trips.length
: TripTabs.maxInlineTabs;
final overflows = tabCount > TripTabs.maxInlineTabs;
final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount;
return <int>[
for (var i = inlineCount; i < tabCount; i++)
if (!inline.contains(i)) i,
];
}
/// Which *slot* the selection occupies. Not the same as the trip index once
/// an overflow menu is in play.
double _slotOf(List<int> inline) {
final i = inline.indexOf(widget.selectedIndex);
return (i < 0 ? 0 : i).toDouble();
}
@override
void didUpdateWidget(TripTabs old) {
super.didUpdateWidget(old);
// Selection is owned by the page, not by this control, so the slide starts
// from here rather than from a tap handler.
final target = _slotOf(_inlineTabs());
if ((target - _pillTween.end!).abs() < 0.001) return;
// Begin from where the pill actually *is*. Starting from the previous
// selection instead would snap it back and re-run the whole journey when the
// rider changes his mind mid-slide.
_pillTween = Tween<double>(begin: _pillPos, end: target);
_pillAnim.forward(from: 0);
}
@override
void dispose() {
_pillAnim.dispose();
super.dispose();
}
/// The colour a segment settles on once the pill is over it.
Color _litColourOf(int tripIndex) {
if (widget.trips.tripAt(tripIndex) == null) {
// Selected-but-empty keeps a readable voice — the rider deliberately
// looked at this slot, so it answers — but never the brand: there is
// nothing here to be mission about.
return ColorConstants.secondaryText;
}
return _percentOf(tripIndex) >= 100
? ColorConstants.acceptGreen
: ColorConstants.primary;
}
/// The colour a segment sits at with the pill elsewhere.
///
/// A finished trip stays green whether or not you are looking at it — "Trip 1,
/// Done" is worth knowing from Trip 2 — so for those the two colours are the
/// same and nothing changes as the pill goes by.
Color _restColourOf(int tripIndex) {
if (widget.trips.tripAt(tripIndex) == null) {
// ── An empty slot is furniture, not a peer ──
//
// At `secondaryText` an unassigned tab was indistinguishable from a
// real unselected trip, so a morning screen read as three equal trips
// when two of them were placeholders. The disabled token is what the
// app already means by "nothing here to act on", and it is exactly one
// voice quieter — the slot is still legible, it has simply stopped
// competing.
return ColorConstants.disabledFill;
}
return _percentOf(tripIndex) >= 100
? ColorConstants.acceptGreen
: ColorConstants.secondaryText;
}
/// ── Slot, not position ──
///
/// Every lookup here goes through [TripSlots.tripAt]. Tab 1 is the morning
/// whether or not the rider has morning work, so `trips[i]` — which meant
/// "the i-th trip he happens to have" — would put an afternoon run under the
/// Trip 1 tab on a day with no morning stops, and the rider's Trip 2 would
/// not be the hub's.
int _percentOf(int tripIndex) =>
widget.trips
.tripAt(tripIndex)
?.completionPercent(
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
) ??
0;
/// True when this trip has a stop in progress — the rider is on it now.
///
/// Derived rather than stored: a trip is running when any of its stops is, and
/// [stopStateOf] already resolves that from the server status plus the local
/// accept/reject sets.
bool _isRunning(Trip trip) => trip.stops.any(
(s) =>
stopStateOf(
s,
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
) ==
StopState.active,
);
@override
Widget build(BuildContext context) {
final inline = _inlineTabs();
final hidden = _hiddenTabs(inline);
// ── A segmented control on a track ──
//
// Three bordered chips became a tab bar became this: one light track holding
// three segments, with the tab you are looking at marked by a pill that
// slides between them. It is the iOS/Material segmented pattern, and it is
// the right one here because the choice is *exclusive and complete* — a
// rider is always looking at exactly one of his three trips, and a track
// makes that set visible as a set.
return Container(
// 16 — the app's base gutter, and the narrowest inset on this screen.
//
// The cards above sit on 28 and the route below on 32, so the track is
// deliberately the widest element in the column. Two reasons it reads
// right rather than misaligned:
//
// • Every card here carries a drop shadow with an 18–20pt blur, which
// bleeds past the card's box on each side. A card's *visible* edge is
// softly wider than its layout edge, while the track is a flat fill
// with a hard edge and no bleed — so matching the numbers exactly made
// the track look inset further than everything around it. It was
// measured at an identical 28 and still read as narrower.
//
// • A segmented control is a *container* for the three trips, not another
// card in the stack. Running it wider than the content it governs is
// the usual way that reads, and it gives each of the three segments
// more room for its label and status line.
margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h),
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
// Layer 1 on the canvas, same as every working surface on Home.
// `neutralLight` separated from the old cream backing by 1.02:1 —
// a track you had to be told about.
color: MilerSurface.working,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
// LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder
// refuses to answer intrinsic-dimension queries, so nesting it under
// IntrinsicHeight throws rather than laying out.
child: LayoutBuilder(
builder: (context, box) {
// The overflow button is a fixed 48 plus a 6 gap, so the segments
// divide what is left. Without this the pill would sit off its tab
// whenever a hub assigned an unusual day.
final reserved = hidden.isEmpty ? 0.0 : 48.w + 6.w;
final slot = (box.maxWidth - reserved) / inline.length;
return AnimatedBuilder(
animation: _pillAnim,
builder: (context, _) {
final pos = _pillPos;
// The trailing underline travels *with* the pill as one object.
// Left in the segments it would have had to blink out under the
// old tab and in under the new one, which is the behaviour being
// removed. Its colour lerps between the two segments it is
// between, so crossing a finished trip shades red → green → red
// rather than snapping at the midpoint.
final lo = pos.floor().clamp(0, inline.length - 1);
final hi = pos.ceil().clamp(0, inline.length - 1);
final markColour = Color.lerp(
_litColourOf(inline[lo]),
_litColourOf(inline[hi]),
pos - pos.floor(),
)!;
// No fixed height: the tab content is text, and a user with large
// font settings overflowed a hard 54.h by a few pixels.
// IntrinsicHeight sizes the row to its tallest tab instead, so
// every tab matches and nothing can overflow.
return IntrinsicHeight(
child: Stack(
children: [
PositionedDirectional(
start: slot * pos,
top: 0,
bottom: 0,
width: slot,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 2.w),
decoration: BoxDecoration(
// The same light glass red the nav bar uses for its
// selection. A white pill was doing the job with a
// drop shadow — fine on its own, but it made this the
// one selected-state in the app that was not the brand
// tint, so the two controls a rider uses most looked
// like they came from different apps.
color: ColorConstants.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
// Keyed so tests can follow it across the strip.
// Identifying it by colour instead breaks the moment
// it travels over an unassigned trip, where it is
// deliberately grey rather than the brand red.
key: TripTabs.markerKey,
height: 3.h,
margin: EdgeInsets.only(
left: 12.w,
right: 12.w,
bottom: 4.h,
),
decoration: BoxDecoration(
color: markColour,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
),
),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (
var slotIndex = 0;
slotIndex < inline.length;
slotIndex++
) ...[
Builder(
builder: (context) {
final i = inline[slotIndex];
// How lit this segment is: 1 with the pill on it,
// 0 once the pill is a whole slot away. Cubed, so
// the glow stays tight around the pill — a linear
// ramp leaves every segment it crosses looking
// half-selected, and the control then reads as
// having no clear selection mid-slide.
final d = (pos - slotIndex).abs();
final t = (1.0 - d).clamp(0.0, 1.0);
final lit = t * t * t;
return Expanded(
child: _Tab(
label: Trip.tabLabel(i),
percent: _percentOf(i),
assigned: widget.trips.tripAt(i) != null,
selected: i == widget.selectedIndex,
active: switch (widget.trips.tripAt(i)) {
final t? => _isRunning(t),
_ => false,
},
lit: lit,
restColour: _restColourOf(i),
litColour: _litColourOf(i),
onTap: () => widget.onSelect(i),
),
);
},
),
if (hidden.isNotEmpty &&
slotIndex == inline.length - 1)
SizedBox(width: 6.w),
],
if (hidden.isNotEmpty)
_OverflowMenu(
trips: widget.trips,
hidden: hidden,
onSelect: widget.onSelect,
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
),
],
),
],
),
);
},
);
},
),
);
}
}
class TripTabsHeader extends SliverPersistentHeaderDelegate {
final List<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
final Set<String> acceptedIds;
final Set<String> rejectedIds;
/// Reserved height. Pass [extentFor] unless you have measured otherwise.
final double extent;
const TripTabsHeader({
required this.trips,
required this.selectedIndex,
required this.onSelect,
required this.extent,
this.acceptedIds = const {},
this.rejectedIds = const {},
});
/// Height to reserve for the bar at this context's text scale.
///
/// Sums what a tab is actually made of — outer margin, border, padding, one
/// line of label, the gap, the progress underline — then adds a deliberate
/// safety margin. Over-reserving is the safe direction; see the class note.
static double extentFor(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final labelLine = scaler.scale(12.5.sp) * 1.4;
final statusLine = scaler.scale(10.5.sp) * 1.4;
return 6
.h // Container margin, 2 top + 4 bottom
+
6
.w // track padding, 3 top + 3 bottom
+
5
.h // lead-in above the label
+
labelLine +
1
.h // gap between label and status
+
statusLine +
4
.h // gap above the running underline
+
3
.h // the underline
+
4
.h // tail below it
+
6; // safety, so a rounding error adds air rather than a clip
}
@override
double get minExtent => extent;
@override
double get maxExtent => extent;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return _PinnedSurface(
// True exactly when stops have scrolled underneath the bar. That is the
// moment it stops being part of the page and starts floating over it, so
// it is the moment it needs an edge — without one the tabs and the top of
// a stop card blur into each other.
floating: overlapsContent || shrinkOffset > 0,
child: TripTabs(
trips: trips,
selectedIndex: selectedIndex,
onSelect: onSelect,
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
),
);
}
@override
bool shouldRebuild(TripTabsHeader old) =>
old.extent != extent ||
old.selectedIndex != selectedIndex ||
old.trips != trips ||
old.acceptedIds != acceptedIds ||
old.rejectedIds != rejectedIds;
}
/// Opaque backing for the pinned bar, with an edge that appears only once
/// content is passing underneath.
class _PinnedSurface extends StatelessWidget {
final bool floating;
final Widget child;
const _PinnedSurface({required this.floating, required this.child});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration(
// Opaque, or the stop cards show through the bar as they pass under
// it. And it is the CANVAS, not `ColorConstants.surface` — that token
// is the warm near-white the surface audit flagged as "warm, unlike
// everything else", and here it painted a cream band across the top
// of a cool grey page. The bar is the page holding its position, so
// it wears the page's own ground; the white segmented track stands on
// it at the full canvas step.
color: MilerSurface.canvas,
border: Border(
bottom: BorderSide(
color: floating ? ColorConstants.borderStrong : Colors.transparent,
width: 1,
),
),
),
// Centred rather than stretched: the reserved extent over-estimates by
// design, so the spare pixels become air above and below instead of
// stretching the tabs into a shape they were not designed for.
child: Center(child: child),
);
}
}
class _Tab extends StatelessWidget {
final String label;
final int percent;
/// False when the hub has not assigned this trip yet.
final bool assigned;
/// The tab the rider is looking at. Used for semantics only — everything
/// painted follows [lit] instead, so the label tracks the pill rather than
/// flipping the instant it is tapped.
final bool selected;
/// The trip actually under way.
final bool active;
/// How close the travelling pill is, 0 → 1.
final double lit;
/// Colour with the pill elsewhere, and colour with the pill on this tab.
final Color restColour;
final Color litColour;
final VoidCallback onTap;
const _Tab({
required this.label,
required this.percent,
required this.selected,
required this.lit,
required this.restColour,
required this.litColour,
required this.onTap,
this.assigned = true,
this.active = false,
});
@override
Widget build(BuildContext context) {
final complete = assigned && percent >= 100;
// ── One thing is marked, and it is the tab you are on ──
//
// Colour follows the pill. It briefly followed a separate "this trip is
// running" signal, and the result on a real screen was that Trip 1 sat
// permanently red and underlined while the rider stood on Trip 2 — two tabs
// claiming to be the important one, and the tap he had just made appearing
// to do nothing. A navigation control has to answer "where am I" before it
// answers anything else.
//
// Running is not lost, it is demoted: the tab still says "Active" in its
// status line, in whatever colour that tab's state calls for. That is the
// right weight for it — useful to know, not something to fight the selection
// for the eye.
final Color accent = Color.lerp(restColour, litColour, lit)!;
final String status = !assigned
// "Not set" read as a chore waiting for the rider. The hub fills
// these slots; all this tab can honestly say is that nothing has
// arrived for it yet.
? 'None yet'
: complete
? 'Done'
: active
? 'Active'
: percent > 0
? '$percent% done'
: '$percent%';
return Semantics(
button: true,
selected: selected,
label: assigned
? '$label, $status${active ? ', in progress' : ''}'
: '$label, not assigned yet',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 5.h),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
// A placeholder slot also gives up the headline weight —
// colour alone was not enough separation on a sunlit LCD.
fontWeight: assigned ? FontWeight.w700 : FontWeight.w600,
letterSpacing: -0.3,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
// Progress as words, not a bar. The underline is reserved for "this
// is the one you are on", and one mark cannot mean two things — but
// "how far through is each trip" is why tabs beat a dropdown here,
// so the figure has to survive somewhere legible.
Text(
status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w600,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 4.h),
// Where the underline used to be drawn. It now travels with the
// pill, but the space it occupied is still reserved here so every
// segment keeps the height `TripTabsHeader.extentFor` reserves for
// it and nothing shifts.
SizedBox(height: 3.h),
SizedBox(height: 4.h),
],
),
),
);
}
}
/// "More" button holding trips 4+ when a hub assigns an unusual day.
class _OverflowMenu extends StatelessWidget {
final List<Trip> trips;
final List<int> hidden;
final ValueChanged<int> onSelect;
final Set<String> acceptedIds;
final Set<String> rejectedIds;
const _OverflowMenu({
required this.trips,
required this.hidden,
required this.onSelect,
required this.acceptedIds,
required this.rejectedIds,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: '${hidden.length} more trips',
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: PopupMenuButton<int>(
onSelected: onSelect,
tooltip: 'More trips',
position: PopupMenuPosition.under,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
itemBuilder: (_) => [
for (final i in hidden)
PopupMenuItem<int>(
value: i,
child: Row(
children: [
Text(
Trip.tabLabel(i),
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 8.w),
Text(
trips.tripAt(i)?.slotLabel ?? 'Not assigned',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
child: Container(
width: 48.w,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.chevronDown,
size: 22.sp,
color: ColorConstants.slateText,
),
Text(
'+${hidden.length}',
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
);
}
}