408 lines
18 KiB
Dart
408 lines
18 KiB
Dart
import 'package:flutter/material.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, 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 is a [TabContainer] now ──
|
||
///
|
||
/// 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.
|
||
///
|
||
/// `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 │
|
||
/// │ ━━━ ││ │ │ │
|
||
/// ├────────╯╰────────┴─┴────────┴──────────╮
|
||
/// │ 10:00 AM – 1:00 PM · 12 to collect │
|
||
/// ╰────────────────────────────────────────╯
|
||
/// ```
|
||
///
|
||
/// ── The rule stayed ──
|
||
///
|
||
/// 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.
|
||
///
|
||
/// 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.
|
||
///
|
||
/// ── The body is the trip's own brief, and nothing else ──
|
||
///
|
||
/// 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-slot count in the brief.
|
||
final Set<String> acceptedIds;
|
||
final Set<String> rejectedIds;
|
||
|
||
/// The selected trip's whole page — brief, route and action bar.
|
||
///
|
||
/// ── 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 selection rule.
|
||
static const Key markerKey = ValueKey('trip_tab_marker');
|
||
|
||
/// The tab strip — [TabContainer.tabExtent].
|
||
///
|
||
/// 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 ──
|
||
///
|
||
/// 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.
|
||
static double get height => pillHeight;
|
||
|
||
const TripSelector({
|
||
super.key,
|
||
required this.trips,
|
||
required this.selectedIndex,
|
||
required this.onSelect,
|
||
this.acceptedIds = const {},
|
||
this.rejectedIds = const {},
|
||
this.body,
|
||
});
|
||
|
||
@override
|
||
State<TripSelector> createState() => _TripSelectorState();
|
||
}
|
||
|
||
class _TripSelectorState extends State<TripSelector>
|
||
with TickerProviderStateMixin {
|
||
TabController? _tabs;
|
||
|
||
/// How many tab slots the strip draws.
|
||
///
|
||
/// 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.
|
||
///
|
||
/// 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();
|
||
_buildController();
|
||
}
|
||
|
||
void _buildController() {
|
||
_tabs?.removeListener(_onTabChanged);
|
||
_tabs?.dispose();
|
||
_tabs = TabController(
|
||
length: _slotCount,
|
||
initialIndex: _safeIndex,
|
||
vsync: this,
|
||
)..addListener(_onTabChanged);
|
||
}
|
||
|
||
/// The controller moved — either the rider tapped, or [didUpdateWidget]
|
||
/// drove it to follow the page.
|
||
///
|
||
/// 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);
|
||
|
||
// 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() {
|
||
_tabs?.removeListener(_onTabChanged);
|
||
_tabs?.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 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 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: 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: [
|
||
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,
|
||
),
|
||
child: body,
|
||
),
|
||
SizedBox(height: TripSelector.contentInset),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One tab: the trip's name, and the rule when it is the one being worked.
|
||
///
|
||
/// 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;
|
||
|
||
const _TripTab({required this.index, required this.selected});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
)
|
||
: null,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|