first commit
This commit is contained in:
707
lib/views/Dashboard/home/trip_tabs.dart
Normal file
707
lib/views/Dashboard/home/trip_tabs.dart
Normal file
@@ -0,0 +1,707 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user