Files
doormile_milderapp/lib/views/Dashboard/home/trip_tabs.dart
2026-09-09 12:55:23 +05:30

408 lines
18 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_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,
),
],
);
}
}