miler map
This commit is contained in:
@@ -1,83 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:tab_container/tab_container.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';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE TRIP SELECTOR — 1 · 2 · 3, under the header on white
|
||||
/// THE TRIP SELECTOR — 1 · 2 · 3, and what the chosen slot is
|
||||
///
|
||||
/// 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 is a [TabContainer] now ──
|
||||
///
|
||||
/// 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.
|
||||
/// The control used to be a bare row of pills with a rule travelling under
|
||||
/// them, drawn by hand: a marker animation, a per-pill width measurement pass,
|
||||
/// and an overflow menu once a hub assigned more than three trips. All of that
|
||||
/// was mechanism — tab geometry, tab transitions, tab overflow — re-implemented
|
||||
/// for one screen.
|
||||
///
|
||||
/// It is a row of pills now, on the white page under the header:
|
||||
/// `tab_container` owns that mechanism now. It draws the tab strip as part of
|
||||
/// the panel's own shape, so the selected tab *is* the top edge of the body
|
||||
/// below it, and it carries the selection between tabs itself. Past three
|
||||
/// trips its tab strip scrolls, which is what the overflow menu was for.
|
||||
///
|
||||
/// ```
|
||||
/// ╭─────────╮ ╭─────────╮ ╭─────────╮
|
||||
/// │ Trip 1 │ │ Trip 2 │ │ Trip 3 │
|
||||
/// ╰─────────╯ ╰─────────╯ ╰─────────╯
|
||||
/// ━━━━━
|
||||
/// ╭────────╮╭────────╮ ╭────────╮
|
||||
/// │ Trip 1 ││ Trip 2 │ │ Trip 3 │
|
||||
/// │ ━━━ ││ │ │ │
|
||||
/// ├────────╯╰────────┴─┴────────┴──────────╮
|
||||
/// │ 10:00 AM – 1:00 PM · 12 to collect │
|
||||
/// ╰────────────────────────────────────────╯
|
||||
/// ```
|
||||
///
|
||||
/// 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.
|
||||
/// ── The rule stayed ──
|
||||
///
|
||||
/// ── Why it came off the brand ground ──
|
||||
/// The package's own idiom is that the selected tab reads as a shaped
|
||||
/// extension of the panel. On its own that is a *filled* tab, and a filled tab
|
||||
/// is the thing this control has twice been redesigned away from — it makes a
|
||||
/// filter look like a primary action and competes with the real button at the
|
||||
/// foot of the card below.
|
||||
///
|
||||
/// 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.
|
||||
/// So selection is still a short brand rule under the chosen numeral, drawn
|
||||
/// inside the tab itself. The panel's shape says which tab owns the body; the
|
||||
/// rule says which trip the rider is on. `test/card_density_test.dart` holds
|
||||
/// both — one mark at a time, and a rule rather than a fill.
|
||||
///
|
||||
/// ── And it says only which trip it is ──
|
||||
/// ── The body is the trip's own brief, and nothing else ──
|
||||
///
|
||||
/// 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.
|
||||
/// A [TabContainer] has a body, and an empty one would be a panel drawn around
|
||||
/// nothing. What goes in it is the brief the tabs choose between — the figures
|
||||
/// for the selected trip — handed in bare, because this panel is already the
|
||||
/// surface they stand on.
|
||||
///
|
||||
/// It carried a line of its own for a version: `1 delivery on this round`,
|
||||
/// under a heading that already read `1 stop left`. One panel, one count,
|
||||
/// stated twice — and on the device the two sat eight points apart. A panel
|
||||
/// under a set of tabs does not also need to narrate what the tabs selected.
|
||||
///
|
||||
/// The panel deliberately does **not** repeat `33 / 50` either. That ratio is
|
||||
/// the headline of the card below in type four times this size; it was on these
|
||||
/// tabs once and was taken off for exactly that reason. 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.
|
||||
/// Optimistic accept/reject sets, for the per-slot count in the brief.
|
||||
final Set<String> acceptedIds;
|
||||
final Set<String> rejectedIds;
|
||||
|
||||
/// Trips shown inline before the rest collapse into the overflow menu.
|
||||
/// The selected trip's whole page — brief, route and action bar.
|
||||
///
|
||||
/// 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.
|
||||
/// ── The panel encloses the work, it does not sit above it ──
|
||||
///
|
||||
/// The selector used to be a control in a sliver of its own, with the trip's
|
||||
/// content in the next sliver down: two unrelated boxes, one of which
|
||||
/// happened to govern the other. A [TabContainer] is a *container*, and the
|
||||
/// thing it is built to contain is the content its tabs choose — so the
|
||||
/// trip's page is handed in here and the selected tab is literally the top
|
||||
/// edge of it.
|
||||
///
|
||||
/// Null leaves the control standing on its own at [height], which is what the
|
||||
/// widget tests measure and what any caller that only wants the tabs gets.
|
||||
final Widget? body;
|
||||
|
||||
/// The number of tab slots ALWAYS rendered.
|
||||
///
|
||||
/// A rider's day is three trips, so 2 and 3 are shown before the hub has
|
||||
/// assigned them. A tab that appears and disappears through the morning makes
|
||||
/// the screen feel unstable; an empty third slot answers the question he
|
||||
/// actually has. Past three, the hub's own count wins and the strip scrolls.
|
||||
static const int maxInlineTabs = 3;
|
||||
|
||||
/// The travelling selection indicator.
|
||||
/// The selection rule.
|
||||
static const Key markerKey = ValueKey('trip_tab_marker');
|
||||
|
||||
/// The control's height — the pill, the air under it, and the indicator.
|
||||
/// The tab strip — [TabContainer.tabExtent].
|
||||
///
|
||||
/// 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.
|
||||
/// 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 => 42.sp;
|
||||
|
||||
/// The rule's thickness — the spec's 2–3dp.
|
||||
static const double indicator = 3;
|
||||
|
||||
/// The panel's own inner padding, measured to land its copy on the same
|
||||
/// vertical as the cards below it.
|
||||
///
|
||||
/// A [MilerPanel] insets its content by its gutter, its padding and the one
|
||||
/// point of border it keeps — which is why `TODAY'S RUN` begins at 29 and why
|
||||
/// `home_gutter_test` measures that number. This panel is a [TabContainer]
|
||||
/// rather than a [MilerPanel], and its caller supplies the gutter, so what is
|
||||
/// left for it to add is the other two parts. Built from the same tokens, not
|
||||
/// from a number that happens to match today.
|
||||
static double get contentInset => MilerSurface.panelPad + 1;
|
||||
|
||||
/// The control's own height: strip plus brief, with no [body] in it.
|
||||
///
|
||||
/// Fixed, because the tabs and the line under them must not move under the
|
||||
/// rider's thumb as trips arrive through the morning. With a [body] the panel
|
||||
/// is as tall as the page it encloses and this is just its head.
|
||||
///
|
||||
/// ── And capped, because it is charged continuously ──
|
||||
///
|
||||
/// This is header furniture: the rider sees it at every scroll position, so
|
||||
/// every point it takes is a point the route below never gets. The budget is
|
||||
/// 80 and `card_density_test.dart` holds it. Adding the body cost the strip
|
||||
/// two points rather than the budget four — the panel earns its height by
|
||||
/// saying what the slot is, not by being taller than the row it replaced.
|
||||
///
|
||||
/// ── Why there is no `.h` in this sum ──
|
||||
///
|
||||
@@ -87,18 +144,7 @@ class TripSelector extends StatefulWidget {
|
||||
/// 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;
|
||||
static double get height => pillHeight;
|
||||
|
||||
const TripSelector({
|
||||
super.key,
|
||||
@@ -107,6 +153,7 @@ class TripSelector extends StatefulWidget {
|
||||
required this.onSelect,
|
||||
this.acceptedIds = const {},
|
||||
this.rejectedIds = const {},
|
||||
this.body,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -114,110 +161,74 @@ class TripSelector extends StatefulWidget {
|
||||
}
|
||||
|
||||
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.
|
||||
with TickerProviderStateMixin {
|
||||
TabController? _tabs;
|
||||
|
||||
/// How many tab slots the strip draws.
|
||||
///
|
||||
/// 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.
|
||||
/// Three is the norm, not a guarantee — a hub can assign more, and when it
|
||||
/// does every one of them gets a tab rather than being folded away.
|
||||
int get _slotCount => widget.trips.length > TripSelector.maxInlineTabs
|
||||
? widget.trips.length
|
||||
: TripSelector.maxInlineTabs;
|
||||
|
||||
/// The selection, clamped into the strip.
|
||||
///
|
||||
/// 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;
|
||||
/// The page owns the selection and is allowed to hold an index this control
|
||||
/// cannot draw — a slot that existed before a refresh shortened the day. A
|
||||
/// [TabController] cannot, so it is clamped here rather than asserted.
|
||||
int get _safeIndex => widget.selectedIndex.clamp(0, _slotCount - 1);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_markerAnim = AnimationController(
|
||||
_buildController();
|
||||
}
|
||||
|
||||
void _buildController() {
|
||||
_tabs?.removeListener(_onTabChanged);
|
||||
_tabs?.dispose();
|
||||
_tabs = TabController(
|
||||
length: _slotCount,
|
||||
initialIndex: _safeIndex,
|
||||
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()),
|
||||
);
|
||||
)..addListener(_onTabChanged);
|
||||
}
|
||||
|
||||
double get _markerPos => _markerTween.evaluate(_markerCurve);
|
||||
|
||||
/// Trip indices rendered as inline segments, left to right.
|
||||
/// The controller moved — either the rider tapped, or [didUpdateWidget]
|
||||
/// drove it to follow the page.
|
||||
///
|
||||
/// 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();
|
||||
/// Reported on `indexIsChanging == false` as well as on settle, so a tap is
|
||||
/// answered as the animation starts rather than a frame after it lands: the
|
||||
/// page swaps the route below, and waiting for the tab to arrive first shows
|
||||
/// the outgoing trip sliding under the incoming label.
|
||||
void _onTabChanged() {
|
||||
final i = _tabs?.index;
|
||||
if (i == null || i == widget.selectedIndex) return;
|
||||
widget.onSelect(i);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
// A day that grew or shrank needs a controller of the new length; there is
|
||||
// no way to re-length one in place.
|
||||
if (_tabs == null || _tabs!.length != _slotCount) {
|
||||
setState(_buildController);
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection is owned by the page, not by this control, so the travel
|
||||
// starts from here rather than from a tap handler. Guarded, or the
|
||||
// listener above and this line drive each other in a loop.
|
||||
if (_tabs!.index != _safeIndex) _tabs!.animateTo(_safeIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_markerAnim.dispose();
|
||||
_tabs?.removeListener(_onTabChanged);
|
||||
_tabs?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -226,347 +237,171 @@ class _TripSelectorState extends State<TripSelector>
|
||||
/// 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.
|
||||
/// first tab 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);
|
||||
final controller = _tabs!;
|
||||
final selected = _safeIndex;
|
||||
|
||||
// With nothing to enclose, the control is the tab strip and nothing else —
|
||||
// a fixed head, which is what `trip_tabs_pinned_test` measures.
|
||||
final body = widget.body;
|
||||
|
||||
return SizedBox(
|
||||
height: TripSelector.height,
|
||||
child: AnimatedBuilder(
|
||||
animation: _markerAnim,
|
||||
builder: (context, _) {
|
||||
final p = _markerPos;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Row(
|
||||
height: body == null ? TripSelector.height : null,
|
||||
child: TabContainer(
|
||||
controller: controller,
|
||||
tabEdge: TabEdge.top,
|
||||
tabExtent: TripSelector.pillHeight,
|
||||
// Wide enough to stay a target at any text scale. Past three trips the
|
||||
// strip scrolls at this width rather than shrinking every tab until
|
||||
// none of them can be hit.
|
||||
tabMinLength: 88.w,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
tabBorderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
// ── The app bar's own red ──
|
||||
//
|
||||
// The body and the selected tab are one shape in one fill, which is
|
||||
// what says which tab owns what is under it. That fill is the brand
|
||||
// bar's red — the same [ColorConstants.brandBarTop] the header above is
|
||||
// painted in, from one definition so the two cannot drift into being
|
||||
// *nearly* the same colour, which reads as a mistake rather than as a
|
||||
// family.
|
||||
//
|
||||
// A gradient is not available here: [TabContainer] paints the tab notch
|
||||
// and the body as one path in one colour. The bar's top stop is the
|
||||
// more saturated of its two and the one the eye reads as "the app's
|
||||
// red", so that is the one a solid takes.
|
||||
//
|
||||
// Everything inside the panel inverts to white with it — the brief
|
||||
// included, which is why it is handed over with
|
||||
// `buildSummary(onBrand: true)` rather than merely bare.
|
||||
//
|
||||
// The *unselected* tabs are not painted by this at all; they stand on
|
||||
// the page canvas beside the panel. That is what makes the control read
|
||||
// as tabs rather than as three words on a red band.
|
||||
color: ColorConstants.brandBarTop,
|
||||
// ── `childPadding` is not applied to a single `child` ──
|
||||
//
|
||||
// The package pads only the `children` branch: `_buildChild` reads
|
||||
// `widget.child ?? Padding(padding: childPadding, …)`, so handing it one
|
||||
// child skips the padding entirely and the brief sat hard against the
|
||||
// screen edge. It is passed anyway for the day this takes `children`,
|
||||
// and the inset that actually runs is [briefInset] below.
|
||||
childPadding: EdgeInsets.symmetric(horizontal: MilerSurface.panelPad),
|
||||
// The tabs are built here, fully styled, so the package must not
|
||||
// re-style them: its own selected/unselected interpolation would scale
|
||||
// the numeral and fight the rule underneath it.
|
||||
overrideTextProperties: true,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOutCubic,
|
||||
tabs: [
|
||||
for (var i = 0; i < _slotCount; i++)
|
||||
_TripTab(index: i, selected: i == selected),
|
||||
],
|
||||
// ── The dropdown is what the tabs are attached to ──
|
||||
//
|
||||
// The brief comes first and touches the tab strip, because the tabs
|
||||
// choose the trip and the brief is that trip's figures: the selected
|
||||
// tab is literally the top edge of the panel holding them. The slot
|
||||
// line closes the panel underneath, where it reads as a caption on the
|
||||
// figures rather than as something between the tabs and their content.
|
||||
child: body == null
|
||||
// Full width, zero height. `SizedBox.shrink()` is 0x0, and the
|
||||
// panel takes its width from its child — so an empty one collapsed
|
||||
// the whole control to nothing and the tabs stopped being tappable
|
||||
// while still being painted.
|
||||
? const SizedBox(width: double.infinity, height: 0)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
),
|
||||
Padding(
|
||||
// Air under the tab strip. Without it the brief's heading
|
||||
// sits on the seam it shares with the selected tab, and the
|
||||
// two read as one squeezed block rather than as a tab and
|
||||
// the panel it opens.
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
TripSelector.contentInset,
|
||||
12,
|
||||
TripSelector.contentInset,
|
||||
0,
|
||||
),
|
||||
],
|
||||
if (hidden.isNotEmpty) ...[
|
||||
SizedBox(width: 10.w),
|
||||
_OverflowChip(
|
||||
width: 44.w,
|
||||
hidden: hidden,
|
||||
trips: widget.trips,
|
||||
onSelect: widget.onSelect,
|
||||
),
|
||||
],
|
||||
child: body,
|
||||
),
|
||||
SizedBox(height: TripSelector.contentInset),
|
||||
],
|
||||
),
|
||||
// ── 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.
|
||||
/// One tab: the trip's name, and the rule when it is the one being worked.
|
||||
///
|
||||
/// See [TripSelector] for why there is no count on it and no card around it.
|
||||
/// See [TripSelector] for why selection is a rule rather than the package's
|
||||
/// own filled tab.
|
||||
class _TripTab extends StatelessWidget {
|
||||
final int index;
|
||||
final bool selected;
|
||||
|
||||
/// 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,
|
||||
});
|
||||
const _TripTab({required this.index, required this.selected});
|
||||
|
||||
@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,
|
||||
),
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Scale-down rather than ellipsis: the numeral is the whole label and
|
||||
// `Trip 3` truncated to `Tri…` names nothing. This is also what keeps
|
||||
// the strip one height at 2.0× text — see [TripSelector.height].
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
Trip.tabLabel(index),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 14.sp,
|
||||
// ── Two grounds, so two inks ──
|
||||
//
|
||||
// The selected tab is part of the red panel; the others stand
|
||||
// on the page canvas beside it. There is no one colour for
|
||||
// both — brand red on the brand bar is invisible, slate on it
|
||||
// fails contrast — so the chosen numeral goes white and the
|
||||
// rest stay slate, quiet enough that the row reads as one
|
||||
// control with one choice in it rather than as three buttons.
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
SizedBox(height: 4.sp),
|
||||
// The rule. Only the selected tab draws it, so there is exactly one in
|
||||
// the tree at a time — which is what `card_density_test` asserts.
|
||||
SizedBox(
|
||||
height: TripSelector.indicator,
|
||||
width: 26.w,
|
||||
child: selected
|
||||
? Container(
|
||||
key: TripSelector.markerKey,
|
||||
decoration: BoxDecoration(
|
||||
// White, because the rule sits on the brand.
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user