708 lines
27 KiB
Dart
708 lines
27 KiB
Dart
import 'package:flutter/material.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';
|
||
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// TRIP TABS — Trip 1 · Trip 2 · Trip 3
|
||
///
|
||
/// A rider is assigned at most three trips a day, so three tabs fit across a
|
||
/// phone and every trip is one tap away. Tabs beat a dropdown here for a
|
||
/// reason: with a dropdown the rider cannot see how many trips he has or how
|
||
/// far along each one is without opening it, and "how much is left today" is
|
||
/// the question he asks most.
|
||
///
|
||
/// Each tab carries its own completion percentage, so the whole day reads at a
|
||
/// glance: `Trip 1 · 100% Trip 2 · 40% Trip 3 · 0%`
|
||
///
|
||
/// **Overflow.** Three is the norm, not a guarantee — a hub can assign more.
|
||
/// Past three the bar keeps the first two tabs and folds the rest into a
|
||
/// "More" menu, rather than shrinking every tab until the labels are
|
||
/// unreadable. The dropdown is the exception path, exactly as asked.
|
||
/// ─────────────────────────────────────────────────────────────────────────
|
||
class TripTabs extends StatefulWidget {
|
||
final List<Trip> trips;
|
||
final int selectedIndex;
|
||
final ValueChanged<int> onSelect;
|
||
|
||
/// Optimistic accept/reject sets, for the per-tab completion figure.
|
||
final Set<String> acceptedIds;
|
||
final Set<String> rejectedIds;
|
||
|
||
/// Tabs shown inline before the rest collapse into the overflow menu.
|
||
///
|
||
/// Also the number of tabs ALWAYS rendered: a rider's day is three trips, so
|
||
/// Trip 2 and Trip 3 are shown even before the hub has assigned them. A tab
|
||
/// that appears and disappears through the morning makes the screen feel
|
||
/// unstable; an empty Trip 3 that says "nothing assigned yet" answers the
|
||
/// question he actually has.
|
||
static const int maxInlineTabs = 3;
|
||
|
||
/// The travelling selection marker at the foot of the pill.
|
||
static const Key markerKey = ValueKey('trip_tab_marker');
|
||
|
||
const TripTabs({
|
||
super.key,
|
||
required this.trips,
|
||
required this.selectedIndex,
|
||
required this.onSelect,
|
||
this.acceptedIds = const {},
|
||
this.rejectedIds = const {},
|
||
});
|
||
|
||
@override
|
||
State<TripTabs> createState() => _TripTabsState();
|
||
}
|
||
|
||
class _TripTabsState extends State<TripTabs>
|
||
with SingleTickerProviderStateMixin {
|
||
/// Carries the pill from the tab it was on to the tab just chosen.
|
||
///
|
||
/// Same mechanism as the bottom nav bar, and for the same reason. The pill
|
||
/// used to belong to each tab — an `AnimatedContainer` per segment whose fill
|
||
/// went from transparent to white — so moving from Trip 3 to Trip 1 faded one
|
||
/// pill 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 Trip 2
|
||
/// with, and the marker appeared to teleport.
|
||
///
|
||
/// There is one pill 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 tint on every label, so a tab warms up as the
|
||
/// pill passes over it instead of switching the instant it is tapped while the
|
||
/// pill 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 _pillAnim = AnimationController(
|
||
vsync: this,
|
||
// Matches the nav bar. A pill crossing two segments needs long enough for
|
||
// the eye to follow it; the 200ms the cross-fade used reads as a jump.
|
||
duration: const Duration(milliseconds: 380),
|
||
value: 1.0,
|
||
);
|
||
|
||
late final Animation<double> _pillCurve = CurvedAnimation(
|
||
parent: _pillAnim,
|
||
curve: Curves.easeInOutCubic,
|
||
);
|
||
|
||
late Tween<double> _pillTween = Tween<double>(
|
||
begin: _slotOf(_inlineTabs()),
|
||
end: _slotOf(_inlineTabs()),
|
||
);
|
||
|
||
double get _pillPos => _pillTween.evaluate(_pillCurve);
|
||
|
||
/// Trip indices rendered as inline segments, left to right.
|
||
///
|
||
/// Three is the norm, not a guarantee — a hub can assign more. Past three the
|
||
/// bar keeps the first two segments and folds the rest into a "More" menu
|
||
/// rather than shrinking every tab until the labels are unreadable. 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 > TripTabs.maxInlineTabs
|
||
? widget.trips.length
|
||
: TripTabs.maxInlineTabs;
|
||
final overflows = tabCount > TripTabs.maxInlineTabs;
|
||
final inlineCount = overflows ? TripTabs.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 > TripTabs.maxInlineTabs
|
||
? widget.trips.length
|
||
: TripTabs.maxInlineTabs;
|
||
final overflows = tabCount > TripTabs.maxInlineTabs;
|
||
final inlineCount = overflows ? TripTabs.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(TripTabs 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 - _pillTween.end!).abs() < 0.001) return;
|
||
// Begin from where the pill 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.
|
||
_pillTween = Tween<double>(begin: _pillPos, end: target);
|
||
_pillAnim.forward(from: 0);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_pillAnim.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// The colour a segment settles on once the pill is over it.
|
||
Color _litColourOf(int tripIndex) {
|
||
if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText;
|
||
return _percentOf(tripIndex) >= 100
|
||
? ColorConstants.acceptGreen
|
||
: ColorConstants.primary;
|
||
}
|
||
|
||
/// The colour a segment sits at with the pill elsewhere.
|
||
///
|
||
/// A finished trip stays green whether or not you are looking at it — "Trip 1,
|
||
/// Done" is worth knowing from Trip 2 — so for those the two colours are the
|
||
/// same and nothing changes as the pill goes by.
|
||
Color _restColourOf(int tripIndex) {
|
||
if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText;
|
||
return _percentOf(tripIndex) >= 100
|
||
? ColorConstants.acceptGreen
|
||
: ColorConstants.secondaryText;
|
||
}
|
||
|
||
int _percentOf(int tripIndex) => widget.trips[tripIndex].completionPercent(
|
||
acceptedIds: widget.acceptedIds,
|
||
rejectedIds: widget.rejectedIds,
|
||
);
|
||
|
||
/// True when this trip has a stop in progress — the rider is on it now.
|
||
///
|
||
/// Derived rather than stored: a trip is running when any of its stops is, and
|
||
/// [stopStateOf] already resolves that from the server status plus the local
|
||
/// accept/reject sets.
|
||
bool _isRunning(Trip trip) => trip.stops.any(
|
||
(s) =>
|
||
stopStateOf(
|
||
s,
|
||
acceptedIds: widget.acceptedIds,
|
||
rejectedIds: widget.rejectedIds,
|
||
) ==
|
||
StopState.active,
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final inline = _inlineTabs();
|
||
final hidden = _hiddenTabs(inline);
|
||
|
||
// ── A segmented control on a track ──
|
||
//
|
||
// Three bordered chips became a tab bar became this: one light track holding
|
||
// three segments, with the tab you are looking at marked by a pill that
|
||
// slides between them. It is the iOS/Material segmented pattern, and it is
|
||
// the right one here because the choice is *exclusive and complete* — a
|
||
// rider is always looking at exactly one of his three trips, and a track
|
||
// makes that set visible as a set.
|
||
return Container(
|
||
// 16 — the app's base gutter, and the narrowest inset on this screen.
|
||
//
|
||
// The cards above sit on 28 and the route below on 32, so the track is
|
||
// deliberately the widest element in the column. Two reasons it reads
|
||
// right rather than misaligned:
|
||
//
|
||
// • Every card here carries a drop shadow with an 18–20pt blur, which
|
||
// bleeds past the card's box on each side. A card's *visible* edge is
|
||
// softly wider than its layout edge, while the track is a flat fill
|
||
// with a hard edge and no bleed — so matching the numbers exactly made
|
||
// the track look inset further than everything around it. It was
|
||
// measured at an identical 28 and still read as narrower.
|
||
//
|
||
// • A segmented control is a *container* for the three trips, not another
|
||
// card in the stack. Running it wider than the content it governs is
|
||
// the usual way that reads, and it gives each of the three segments
|
||
// more room for its label and status line.
|
||
margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h),
|
||
padding: EdgeInsets.all(3.w),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.neutralLight,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
// LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder
|
||
// refuses to answer intrinsic-dimension queries, so nesting it under
|
||
// IntrinsicHeight throws rather than laying out.
|
||
child: LayoutBuilder(
|
||
builder: (context, box) {
|
||
// The overflow button is a fixed 48 plus a 6 gap, so the segments
|
||
// divide what is left. Without this the pill would sit off its tab
|
||
// whenever a hub assigned an unusual day.
|
||
final reserved = hidden.isEmpty ? 0.0 : 48.w + 6.w;
|
||
final slot = (box.maxWidth - reserved) / inline.length;
|
||
|
||
return AnimatedBuilder(
|
||
animation: _pillAnim,
|
||
builder: (context, _) {
|
||
final pos = _pillPos;
|
||
|
||
// The trailing underline travels *with* the pill as one object.
|
||
// Left in the segments it would have had to blink out under the
|
||
// old tab and in under the new one, which is the behaviour being
|
||
// removed. Its colour lerps between the two segments it is
|
||
// between, so crossing a finished trip shades red → green → red
|
||
// rather than snapping at the midpoint.
|
||
final lo = pos.floor().clamp(0, inline.length - 1);
|
||
final hi = pos.ceil().clamp(0, inline.length - 1);
|
||
final markColour = Color.lerp(
|
||
_litColourOf(inline[lo]),
|
||
_litColourOf(inline[hi]),
|
||
pos - pos.floor(),
|
||
)!;
|
||
|
||
// No fixed height: the tab content is text, and a user with large
|
||
// font settings overflowed a hard 54.h by a few pixels.
|
||
// IntrinsicHeight sizes the row to its tallest tab instead, so
|
||
// every tab matches and nothing can overflow.
|
||
return IntrinsicHeight(
|
||
child: Stack(
|
||
children: [
|
||
PositionedDirectional(
|
||
start: slot * pos,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: slot,
|
||
child: Container(
|
||
margin: EdgeInsets.symmetric(horizontal: 2.w),
|
||
decoration: BoxDecoration(
|
||
// The same light glass red the nav bar uses for its
|
||
// selection. A white pill was doing the job with a
|
||
// drop shadow — fine on its own, but it made this the
|
||
// one selected-state in the app that was not the brand
|
||
// tint, so the two controls a rider uses most looked
|
||
// like they came from different apps.
|
||
color: ColorConstants.primary.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
),
|
||
child: Align(
|
||
alignment: Alignment.bottomCenter,
|
||
child: Container(
|
||
// Keyed so tests can follow it across the strip.
|
||
// Identifying it by colour instead breaks the moment
|
||
// it travels over an unassigned trip, where it is
|
||
// deliberately grey rather than the brand red.
|
||
key: TripTabs.markerKey,
|
||
height: 3.h,
|
||
margin: EdgeInsets.only(
|
||
left: 12.w,
|
||
right: 12.w,
|
||
bottom: 4.h,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: markColour,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
for (
|
||
var slotIndex = 0;
|
||
slotIndex < inline.length;
|
||
slotIndex++
|
||
) ...[
|
||
Builder(
|
||
builder: (context) {
|
||
final i = inline[slotIndex];
|
||
// How lit this segment is: 1 with the pill on it,
|
||
// 0 once the pill is a whole slot away. Cubed, so
|
||
// the glow stays tight around the pill — a linear
|
||
// ramp leaves every segment it crosses looking
|
||
// half-selected, and the control then reads as
|
||
// having no clear selection mid-slide.
|
||
final d = (pos - slotIndex).abs();
|
||
final t = (1.0 - d).clamp(0.0, 1.0);
|
||
final lit = t * t * t;
|
||
|
||
return Expanded(
|
||
child: _Tab(
|
||
label: Trip.tabLabel(i),
|
||
percent: i < widget.trips.length
|
||
? _percentOf(i)
|
||
: 0,
|
||
assigned: i < widget.trips.length,
|
||
selected: i == widget.selectedIndex,
|
||
active:
|
||
i < widget.trips.length &&
|
||
_isRunning(widget.trips[i]),
|
||
lit: lit,
|
||
restColour: _restColourOf(i),
|
||
litColour: _litColourOf(i),
|
||
onTap: () => widget.onSelect(i),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
if (hidden.isNotEmpty &&
|
||
slotIndex == inline.length - 1)
|
||
SizedBox(width: 6.w),
|
||
],
|
||
if (hidden.isNotEmpty)
|
||
_OverflowMenu(
|
||
trips: widget.trips,
|
||
hidden: hidden,
|
||
onSelect: widget.onSelect,
|
||
acceptedIds: widget.acceptedIds,
|
||
rejectedIds: widget.rejectedIds,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class TripTabsHeader extends SliverPersistentHeaderDelegate {
|
||
final List<Trip> trips;
|
||
final int selectedIndex;
|
||
final ValueChanged<int> onSelect;
|
||
final Set<String> acceptedIds;
|
||
final Set<String> rejectedIds;
|
||
|
||
/// Reserved height. Pass [extentFor] unless you have measured otherwise.
|
||
final double extent;
|
||
|
||
const TripTabsHeader({
|
||
required this.trips,
|
||
required this.selectedIndex,
|
||
required this.onSelect,
|
||
required this.extent,
|
||
this.acceptedIds = const {},
|
||
this.rejectedIds = const {},
|
||
});
|
||
|
||
/// Height to reserve for the bar at this context's text scale.
|
||
///
|
||
/// Sums what a tab is actually made of — outer margin, border, padding, one
|
||
/// line of label, the gap, the progress underline — then adds a deliberate
|
||
/// safety margin. Over-reserving is the safe direction; see the class note.
|
||
static double extentFor(BuildContext context) {
|
||
final scaler = MediaQuery.textScalerOf(context);
|
||
final labelLine = scaler.scale(12.5.sp) * 1.4;
|
||
final statusLine = scaler.scale(10.5.sp) * 1.4;
|
||
return 6
|
||
.h // Container margin, 2 top + 4 bottom
|
||
+
|
||
6
|
||
.w // track padding, 3 top + 3 bottom
|
||
+
|
||
5
|
||
.h // lead-in above the label
|
||
+
|
||
labelLine +
|
||
1
|
||
.h // gap between label and status
|
||
+
|
||
statusLine +
|
||
4
|
||
.h // gap above the running underline
|
||
+
|
||
3
|
||
.h // the underline
|
||
+
|
||
4
|
||
.h // tail below it
|
||
+
|
||
6; // safety, so a rounding error adds air rather than a clip
|
||
}
|
||
|
||
@override
|
||
double get minExtent => extent;
|
||
|
||
@override
|
||
double get maxExtent => extent;
|
||
|
||
@override
|
||
Widget build(
|
||
BuildContext context,
|
||
double shrinkOffset,
|
||
bool overlapsContent,
|
||
) {
|
||
return _PinnedSurface(
|
||
// True exactly when stops have scrolled underneath the bar. That is the
|
||
// moment it stops being part of the page and starts floating over it, so
|
||
// it is the moment it needs an edge — without one the tabs and the top of
|
||
// a stop card blur into each other.
|
||
floating: overlapsContent || shrinkOffset > 0,
|
||
child: TripTabs(
|
||
trips: trips,
|
||
selectedIndex: selectedIndex,
|
||
onSelect: onSelect,
|
||
acceptedIds: acceptedIds,
|
||
rejectedIds: rejectedIds,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
bool shouldRebuild(TripTabsHeader old) =>
|
||
old.extent != extent ||
|
||
old.selectedIndex != selectedIndex ||
|
||
old.trips != trips ||
|
||
old.acceptedIds != acceptedIds ||
|
||
old.rejectedIds != rejectedIds;
|
||
}
|
||
|
||
/// Opaque backing for the pinned bar, with an edge that appears only once
|
||
/// content is passing underneath.
|
||
class _PinnedSurface extends StatelessWidget {
|
||
final bool floating;
|
||
final Widget child;
|
||
|
||
const _PinnedSurface({required this.floating, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 160),
|
||
decoration: BoxDecoration(
|
||
// Opaque, or the stop cards show through the bar as they pass under it.
|
||
color: ColorConstants.surface,
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: floating ? ColorConstants.borderStrong : Colors.transparent,
|
||
width: 1,
|
||
),
|
||
),
|
||
),
|
||
// Centred rather than stretched: the reserved extent over-estimates by
|
||
// design, so the spare pixels become air above and below instead of
|
||
// stretching the tabs into a shape they were not designed for.
|
||
child: Center(child: child),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Tab extends StatelessWidget {
|
||
final String label;
|
||
final int percent;
|
||
|
||
/// False when the hub has not assigned this trip yet.
|
||
final bool assigned;
|
||
|
||
/// The tab the rider is looking at. Used for semantics only — everything
|
||
/// painted follows [lit] instead, so the label tracks the pill rather than
|
||
/// flipping the instant it is tapped.
|
||
final bool selected;
|
||
|
||
/// The trip actually under way.
|
||
final bool active;
|
||
|
||
/// How close the travelling pill is, 0 → 1.
|
||
final double lit;
|
||
|
||
/// Colour with the pill elsewhere, and colour with the pill on this tab.
|
||
final Color restColour;
|
||
final Color litColour;
|
||
|
||
final VoidCallback onTap;
|
||
|
||
const _Tab({
|
||
required this.label,
|
||
required this.percent,
|
||
required this.selected,
|
||
required this.lit,
|
||
required this.restColour,
|
||
required this.litColour,
|
||
required this.onTap,
|
||
this.assigned = true,
|
||
this.active = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final complete = assigned && percent >= 100;
|
||
|
||
// ── One thing is marked, and it is the tab you are on ──
|
||
//
|
||
// Colour follows the pill. It briefly followed a separate "this trip is
|
||
// running" signal, and the result on a real screen was that Trip 1 sat
|
||
// permanently red and underlined while the rider stood on Trip 2 — two tabs
|
||
// claiming to be the important one, and the tap he had just made appearing
|
||
// to do nothing. A navigation control has to answer "where am I" before it
|
||
// answers anything else.
|
||
//
|
||
// Running is not lost, it is demoted: the tab still says "Active" in its
|
||
// status line, in whatever colour that tab's state calls for. That is the
|
||
// right weight for it — useful to know, not something to fight the selection
|
||
// for the eye.
|
||
final Color accent = Color.lerp(restColour, litColour, lit)!;
|
||
|
||
final String status = !assigned
|
||
? 'Not set'
|
||
: complete
|
||
? 'Done'
|
||
: active
|
||
? 'Active'
|
||
: percent > 0
|
||
? '$percent% done'
|
||
: '$percent%';
|
||
|
||
return Semantics(
|
||
button: true,
|
||
selected: selected,
|
||
label: assigned
|
||
? '$label, $status${active ? ', in progress' : ''}'
|
||
: '$label, not assigned yet',
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SizedBox(height: 5.h),
|
||
Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 12.5.sp,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: -0.3,
|
||
color: accent,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 1.h),
|
||
// Progress as words, not a bar. The underline is reserved for "this
|
||
// is the one you are on", and one mark cannot mean two things — but
|
||
// "how far through is each trip" is why tabs beat a dropdown here,
|
||
// so the figure has to survive somewhere legible.
|
||
Text(
|
||
status,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 10.5.sp,
|
||
fontWeight: FontWeight.w700,
|
||
color: accent,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 4.h),
|
||
// Where the underline used to be drawn. It now travels with the
|
||
// pill, but the space it occupied is still reserved here so every
|
||
// segment keeps the height `TripTabsHeader.extentFor` reserves for
|
||
// it and nothing shifts.
|
||
SizedBox(height: 3.h),
|
||
SizedBox(height: 4.h),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// "More" button holding trips 4+ when a hub assigns an unusual day.
|
||
class _OverflowMenu extends StatelessWidget {
|
||
final List<Trip> trips;
|
||
final List<int> hidden;
|
||
final ValueChanged<int> onSelect;
|
||
final Set<String> acceptedIds;
|
||
final Set<String> rejectedIds;
|
||
|
||
const _OverflowMenu({
|
||
required this.trips,
|
||
required this.hidden,
|
||
required this.onSelect,
|
||
required this.acceptedIds,
|
||
required this.rejectedIds,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Semantics(
|
||
button: true,
|
||
label: '${hidden.length} more trips',
|
||
child: Material(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
child: PopupMenuButton<int>(
|
||
onSelected: onSelect,
|
||
tooltip: 'More trips',
|
||
position: PopupMenuPosition.under,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
itemBuilder: (_) => [
|
||
for (final i in hidden)
|
||
PopupMenuItem<int>(
|
||
value: i,
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
Trip.tabLabel(i),
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
fontWeight: FontWeight.w800,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(width: 8.w),
|
||
Text(
|
||
i < trips.length ? trips[i].slotLabel : 'Not assigned',
|
||
style: TextStyle(
|
||
fontSize: 11.5.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
child: Container(
|
||
width: 48.w,
|
||
alignment: Alignment.center,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.expand_more_rounded,
|
||
size: 22.sp,
|
||
color: ColorConstants.slateText,
|
||
),
|
||
Text(
|
||
'+${hidden.length}',
|
||
style: TextStyle(
|
||
fontSize: 11.sp,
|
||
fontWeight: FontWeight.w800,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|