573 lines
22 KiB
Dart
573 lines
22 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|