Files
doormile_milderapp/lib/views/Dashboard/home/trip_tabs.dart
2026-08-28 11:13:15 +05:30

573 lines
22 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:flutter/rendering.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/home/trip.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';
/// ─────────────────────────────────────────────────────────────────────────
/// THE TRIP SELECTOR — 1 · 2 · 3, under the header on white
///
/// A rider is assigned at most three trips a day, so three targets fit across a
/// phone and every trip is one tap away.
///
/// ── It has no container ──
///
/// It has had three, and each was a box drawn around a control that already
/// had a boundary of its own. First a pinned grey strip below the header, with
/// its own hairline. Then chips floating on the brand. Then a translucent
/// rounded track holding three rounded segments — a rounded rectangle inside a
/// rounded rectangle inside the header's own rounded rectangle, three nested
/// shapes to say which of three words is selected.
///
/// It is a row of pills now, on the white page under the header:
///
/// ```
/// ╭─────────╮ ╭─────────╮ ╭─────────╮
/// │ Trip 1 │ │ Trip 2 │ │ Trip 3 │
/// ╰─────────╯ ╰─────────╯ ╰─────────╯
/// ━━━━━
/// ```
///
/// The selected trip is a white pill with a brand-red edge and brand-red type,
/// under a short red rule that travels; the others are a quiet grey fill with
/// grey type. Nothing here is a card, nothing is nested, and the row sizes
/// itself to its labels rather than dividing the width into three — which is
/// what makes it read as a filter rather than as a segmented control.
///
/// ── Why it came off the brand ground ──
///
/// It sat inside the red header for a while, which put the one control on the
/// screen that changes what the *page below* shows inside the one surface that
/// never changes. On white it belongs to the content it governs, the header
/// goes back to being identity and duty, and the red is an accent on a page
/// rather than a lighter shade of the thing it sits on.
///
/// ── And it says only which trip it is ──
///
/// The tabs carried `33 / 50` under each label for a while. That figure is the
/// run's, not the tab's: it is the headline of the card immediately below, in
/// type four times the size, and it was the third place on one screen the same
/// ratio appeared. A filter says what it filters to.
///
/// ─────────────────────────────────────────────────────────────────────────
class TripSelector extends StatefulWidget {
final List<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
/// Optimistic accept/reject sets, for the per-trip count.
final Set<String> acceptedIds;
final Set<String> rejectedIds;
/// Trips shown inline before the rest collapse into the overflow menu.
///
/// Also the number of segments ALWAYS rendered: a rider's day is three trips,
/// so 2 and 3 are shown before the hub has assigned them. A segment that
/// appears and disappears through the morning makes the screen feel unstable;
/// an empty third slot answers the question he actually has.
static const int maxInlineTabs = 3;
/// The travelling selection indicator.
static const Key markerKey = ValueKey('trip_tab_marker');
/// The control's height — the pill, the air under it, and the indicator.
///
/// Fixed, because it sits in a header whose extent must not move as trips
/// arrive through the morning, and tight because every point of it is a point
/// of the rider's first stop.
///
/// ── Why there is no `.h` in this sum ──
///
/// The parts used to be a mix: type in `.sp`, which ScreenUtil scales by
/// screen **width**, and gaps in `.h`, which it scales by **height**. On a
/// phone shaped like the design size those agree and nothing shows. On
/// anything shorter and wider — a foldable open, a tablet in landscape, a
/// test surface — the box shrinks on one axis while the type inside it does
/// not, and the label gets squeezed while still technically fitting.
///
/// So the control is measured on one axis only: [pillHeight] · 7 ·
/// [indicator].
static double get height => pillHeight + 7 + indicator;
/// The pill itself. Comfortably over the 44pt tap floor without being a
/// button — this is a filter, and a filter that looks like a primary action
/// competes with the one at the foot of the card below it.
static double get pillHeight => 40.sp;
/// The travelling rule's thickness — the spec's 2–3dp.
static const double indicator = 3;
const TripSelector({
super.key,
required this.trips,
required this.selectedIndex,
required this.onSelect,
this.acceptedIds = const {},
this.rejectedIds = const {},
});
@override
State<TripSelector> createState() => _TripSelectorState();
}
class _TripSelectorState extends State<TripSelector>
// Two tickers: the marker's journey and the running trip's pulse. They are
// independent — the pulse repeats forever, the marker fires on a tap — so
// they cannot share one controller.
with
TickerProviderStateMixin {
/// Carries the marker from the segment it was on to the one just chosen.
///
/// Same mechanism as the bottom nav bar, and for the same reason. The marker
/// used to belong to each segment — an `AnimatedContainer` per tab whose fill
/// went from transparent to white — so moving from 3 to 1 faded one 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 2 with, and the
/// marker appeared to teleport.
///
/// There is one marker 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 ink on every segment, so one warms up as the
/// marker passes over it instead of switching the instant it is tapped while
/// the marker 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 _markerAnim;
late final Animation<double> _markerCurve;
late Tween<double> _markerTween;
@override
void initState() {
super.initState();
_markerAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
value: 1.0,
);
_markerCurve = CurvedAnimation(
parent: _markerAnim,
curve: Curves.easeInOutCubic,
);
_markerTween = Tween<double>(
begin: _slotOf(_inlineTabs()),
end: _slotOf(_inlineTabs()),
);
}
double get _markerPos => _markerTween.evaluate(_markerCurve);
/// Trip indices rendered as inline segments, left to right.
///
/// Three is the norm, not a guarantee — a hub can assign more. Past three the
/// control keeps the first two segments and folds the rest into a menu rather
/// than shrinking every target until nothing can be hit. 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 > TripSelector.maxInlineTabs
? widget.trips.length
: TripSelector.maxInlineTabs;
final overflows = tabCount > TripSelector.maxInlineTabs;
final inlineCount = overflows ? TripSelector.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 > TripSelector.maxInlineTabs
? widget.trips.length
: TripSelector.maxInlineTabs;
final overflows = tabCount > TripSelector.maxInlineTabs;
final inlineCount = overflows ? TripSelector.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(TripSelector 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 - _markerTween.end!).abs() < 0.001) return;
// Begin from where the marker 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.
_markerTween = Tween<double>(begin: _markerPos, end: target);
_markerAnim.forward(from: 0);
}
@override
void dispose() {
_markerAnim.dispose();
super.dispose();
}
/// ── Slot, not position ──
///
/// Every lookup goes through [TripSlots.tripAt]. Slot 1 is the morning
/// whether or not the rider has morning work, so `trips[i]` — which means
/// "the i-th trip he happens to have" — would put an afternoon run under the
/// first segment on a day with no morning stops, and the rider's trip 2 would
/// not be the hub's.
@override
Widget build(BuildContext context) {
final inline = _inlineTabs();
final hidden = _hiddenTabs(inline);
return SizedBox(
height: TripSelector.height,
child: AnimatedBuilder(
animation: _markerAnim,
builder: (context, _) {
final p = _markerPos;
return Stack(
children: [
Row(
children: [
for (var i = 0; i < inline.length; i++) ...[
if (i > 0) SizedBox(width: 10.w),
// ── Flexible, because the labels grow ──
//
// The pills size themselves to their text and the text
// scales with the system font. Three of them at 2.0× are
// wider than a 320pt phone, and a `Row` of fixed-width
// children overflows rather than compressing. Loose flex
// lets each take its natural width until there is no room,
// and the label scales down inside it after that.
Flexible(
child: _TripTab(
index: inline[i],
// How lit this pill is: 1 with the rule under it, 0
// once the rule is a whole slot away. Tied to the
// rule's position rather than to the selection, so a
// pill warms as the rule crosses instead of flipping
// the instant it is tapped while the rule is two slots
// away.
lit: (1.0 - (p - i).abs()).clamp(0.0, 1.0),
onTap: () => widget.onSelect(inline[i]),
onMeasured: (w) => _measure(i, w),
),
),
],
if (hidden.isNotEmpty) ...[
SizedBox(width: 10.w),
_OverflowChip(
width: 44.w,
hidden: hidden,
trips: widget.trips,
onSelect: widget.onSelect,
),
],
],
),
// ── The rule, travelling ──
//
// One object that moves, rather than a mark each pill draws for
// itself: moving from 3 to 1 used to fade one out on the right
// and a different one in on the left, with nothing ever occupying
// the space between, so it appeared to teleport across tab 2.
//
// The pills size themselves to their labels, so where the rule
// has to be is not arithmetic — it is measured, from the pills
// themselves. See [_measure].
if (_widths.length >= inline.length)
Positioned(
left: _leftOf(p),
bottom: 0,
width: _widthAt(p),
child: Center(
child: FractionallySizedBox(
widthFactor: 0.52,
child: Container(
key: TripSelector.markerKey,
height: TripSelector.indicator,
decoration: BoxDecoration(
color: ColorConstants.primary,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
// The one piece of decoration in this control, spent
// on the mark that says where you are: its own colour
// at a third, blurred wide, so the rule reads as lit
// rather than as a bar with a shadow.
boxShadow: [
BoxShadow(
color: ColorConstants.primary.withValues(
alpha: 0.34,
),
blurRadius: 9,
offset: const Offset(0, 3),
),
],
),
),
),
),
),
],
);
},
),
);
}
/// Every pill's measured width, by slot.
///
/// A pill is as wide as its label, so the travelling rule cannot be placed by
/// dividing the row into equal parts. Each pill reports its width on layout
/// and the rule is positioned from the running total — which is also what
/// lets it slide smoothly between two pills of different widths.
final Map<int, double> _widths = {};
void _measure(int slot, double width) {
if (_widths[slot] == width) return;
_widths[slot] = width;
// After layout, so a rebuild here would be a build-during-build.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
/// The gap between pills, matching the `SizedBox` in the row.
double get _gap => 10.w;
double _leftOf(double p) {
final i = p.floor();
final f = p - i;
double x = 0;
for (var k = 0; k < i; k++) {
x += (_widths[k] ?? 0) + _gap;
}
// Between two pills, walk the gap in proportion.
return x + f * ((_widths[i] ?? 0) + _gap);
}
double _widthAt(double p) {
final i = p.floor();
final f = p - i;
final a = _widths[i] ?? 0;
final b = _widths[i + 1] ?? a;
return a + (b - a) * f;
}
}
/// One pill: the trip's name, and whether it is the one being worked.
///
/// See [TripSelector] for why there is no count on it and no card around it.
class _TripTab extends StatelessWidget {
final int index;
/// 0 → 1, how far the travelling rule is over this pill.
final double lit;
final VoidCallback onTap;
/// Reports this pill's laid-out width, so the rule can be placed against
/// pills that are as wide as their labels rather than evenly divided.
final ValueChanged<double> onMeasured;
const _TripTab({
required this.index,
required this.lit,
required this.onTap,
required this.onMeasured,
});
@override
Widget build(BuildContext context) {
// ── Selection is colour and edge, not fill ──
//
// The selected pill goes *lighter* — white, with a brand edge and brand
// type — and the unselected ones carry the grey. That is the inverse of the
// usual segmented control, and it is right here because the page under it
// is white: a filled red pill would be the heaviest object on the screen,
// competing with the action at the foot of the card below.
final Color ink = Color.lerp(
ColorConstants.secondaryText,
ColorConstants.primary,
lit,
)!;
final Color fill = Color.lerp(
ColorConstants.tint(ColorConstants.slateText, 0.06),
ColorConstants.pureSurface,
lit,
)!;
final Color edge = Color.lerp(
Colors.transparent,
ColorConstants.primary.withValues(alpha: 0.55),
lit,
)!;
return Semantics(
button: true,
selected: lit >= 0.999,
label: 'Trip ${index + 1}',
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: _Measured(
onMeasured: onMeasured,
child: AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOut,
height: TripSelector.pillHeight,
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
border: Border.all(color: edge, width: 1.4),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
// Sentence case. `TRIP 1` set in caps is a label shouting a
// proper noun; the tab is the trip's name, and a name is written
// the way it is said.
'Trip ${index + 1}',
maxLines: 1,
style: TextStyle(
fontSize: 14.5.sp,
height: 1.0,
// Weight carries the selection alongside the colour, so the
// state survives a greyscale screenshot and a rider with a
// colour-shifted screen.
fontWeight: lit > 0.5 ? FontWeight.w700 : FontWeight.w600,
letterSpacing: -0.1,
color: ink,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
),
);
}
}
/// Reports its child's laid-out width once, after layout.
///
/// A `LayoutBuilder` cannot do this: it hands over the *incoming constraints*,
/// and these pills are unconstrained on the main axis — they are as wide as
/// their own text. So the width is read off the render object instead, in the
/// frame after it exists.
class _Measured extends SingleChildRenderObjectWidget {
final ValueChanged<double> onMeasured;
const _Measured({required this.onMeasured, required super.child});
@override
_MeasuredBox createRenderObject(BuildContext context) =>
_MeasuredBox(onMeasured);
@override
void updateRenderObject(BuildContext context, _MeasuredBox box) {
box.onMeasured = onMeasured;
}
}
class _MeasuredBox extends RenderProxyBox {
ValueChanged<double> onMeasured;
_MeasuredBox(this.onMeasured);
@override
void performLayout() {
super.performLayout();
onMeasured(size.width);
}
}
/// The way into trips four and beyond, on the rare day a hub assigns them.
class _OverflowChip extends StatelessWidget {
final double width;
final List<int> hidden;
final List<Trip> trips;
final ValueChanged<int> onSelect;
const _OverflowChip({
required this.width,
required this.hidden,
required this.trips,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
child: Semantics(
button: true,
label: '${hidden.length} more trips',
excludeSemantics: true,
child: PopupMenuButton<int>(
tooltip: '',
padding: EdgeInsets.zero,
color: ColorConstants.pureSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
onSelected: onSelect,
itemBuilder: (context) => [
for (final i in hidden)
PopupMenuItem<int>(
value: i,
child: Text(
// The one place a word is worth spending: a menu row has no
// shape of its own to carry the meaning.
'Trip ${i + 1}',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(20.r),
),
child: Center(
child: Text(
'+${hidden.length}',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
),
);
}
}