first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View 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,
),
),
],
),
),
),
),
);
}
}