miler map
This commit is contained in:
@@ -128,7 +128,7 @@ class AcceptedPill extends StatelessWidget {
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.onAccent,
|
||||
@@ -152,7 +152,7 @@ class AcceptedPill extends StatelessWidget {
|
||||
_waiting(profile, count),
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.1,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
|
||||
@@ -96,7 +96,7 @@ class DutySheet extends StatelessWidget {
|
||||
goingOnline ? 'Go on duty?' : 'Go off duty?',
|
||||
style: TextStyle(
|
||||
fontSize: 21.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -123,7 +123,7 @@ class DutySheet extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
@@ -161,7 +161,7 @@ class DutySheet extends StatelessWidget {
|
||||
goingOnline ? 'Go on duty' : 'Go off duty',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.onAccent,
|
||||
@@ -189,7 +189,7 @@ class DutySheet extends StatelessWidget {
|
||||
goingOnline ? 'Not yet' : 'Stay on duty',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
|
||||
@@ -226,7 +226,7 @@ class HomeHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFA51C1C), Color(0xFF8B1A1A)],
|
||||
colors: [ColorConstants.brandBarTop, ColorConstants.brandBarBottom],
|
||||
),
|
||||
),
|
||||
child: MediaQuery.withClampedTextScaling(
|
||||
@@ -370,7 +370,7 @@ class _ExpandedBody extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1.6,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -400,7 +400,7 @@ class _ExpandedBody extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
height: 1.15,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.6,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -463,7 +463,7 @@ class _CollapsedRow extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -598,7 +598,7 @@ class DutyPill extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.8,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
|
||||
@@ -63,7 +63,6 @@ import 'package:miler/data/consignment_state.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/collect_payment.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/widget/Bottom_page.dart';
|
||||
import 'package:miler/data/meal_run_mock.dart';
|
||||
import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/order_events.dart';
|
||||
@@ -233,6 +232,11 @@ class _HomepageState extends State<Homepage>
|
||||
/// "picked", and `picked` is the delivery. See the collected store.
|
||||
Set<String> _collectedIds = <String>{};
|
||||
|
||||
/// `next_action` per order, as the pivot returned it. Home reads it only to
|
||||
/// count what is waiting on the other tab — the two screens must hand
|
||||
/// [WorkBoundary] the same arguments or their counts drift apart.
|
||||
Map<String, String> _pivotActions = <String, String>{};
|
||||
|
||||
/// Orders the rider has written off today — delivered, cancelled or skipped.
|
||||
///
|
||||
/// The union of the completed and skipped stores, both of which record a
|
||||
@@ -291,6 +295,9 @@ class _HomepageState extends State<Homepage>
|
||||
// booking already carries. One request, cached, and every failure resolves
|
||||
// to "use the row's own name" — see [PickupLocations].
|
||||
unawaited(PickupLocations.ensureLoaded());
|
||||
// The building the logistics run is headed by — see [RiderHub]. Read from
|
||||
// prefs the login wrote, so it costs nothing and cannot fail the fetch.
|
||||
unawaited(RiderHub.ensureLoaded());
|
||||
BottomPage.currentTab.addListener(_onTabChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_fetchQueues();
|
||||
@@ -677,7 +684,7 @@ class _HomepageState extends State<Homepage>
|
||||
'Not at Pickup Location',
|
||||
style: TextStyle(
|
||||
fontSize: FontConstants.xLarge(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: _textPrimary,
|
||||
),
|
||||
@@ -704,7 +711,7 @@ class _HomepageState extends State<Homepage>
|
||||
'OK',
|
||||
style: TextStyle(
|
||||
fontSize: FontConstants.regular(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -764,7 +771,7 @@ class _HomepageState extends State<Homepage>
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: _textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
@@ -1180,6 +1187,7 @@ class _HomepageState extends State<Homepage>
|
||||
final acceptedIds = await getAcceptedOrderIds();
|
||||
final rejectedIds = await getRejectedOrderIds();
|
||||
final collectedIds = await getCollectedOrderIds();
|
||||
final pivotActions = await getPivotNextActions();
|
||||
// Stops parked for a return visit. They are Activity's now — see the
|
||||
// stamping loop below and `TripCard._showsOnHome`.
|
||||
final skippedIds = await getSkippedOrderIds();
|
||||
@@ -1330,6 +1338,7 @@ class _HomepageState extends State<Homepage>
|
||||
_acceptedIds = acceptedIds;
|
||||
_rejectedIds = rejectedIds;
|
||||
_collectedIds = collectedIds;
|
||||
_pivotActions = pivotActions;
|
||||
_ordersMap = newOrdersMap;
|
||||
_orderIds = newOrderIds;
|
||||
_selectedOrders = newSelection;
|
||||
@@ -1670,14 +1679,6 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
if (base == null) return;
|
||||
|
||||
// A telemetry log about a booking the backend has never issued is a row
|
||||
// nobody can join to anything. The meal line's stops exist only on this
|
||||
// phone until the endpoints land — see [MealRunMock].
|
||||
if (MealRunMock.active) {
|
||||
debugPrint('[MEAL_MOCK] pickup log not posted for $orderId');
|
||||
return;
|
||||
}
|
||||
|
||||
final coords = await _getValidCoordinates();
|
||||
final now = DateTime.now();
|
||||
final logdate =
|
||||
@@ -1719,7 +1720,7 @@ class _HomepageState extends State<Homepage>
|
||||
if (selectedOrderIds.isEmpty) return "Accept Bookings";
|
||||
|
||||
final statuses = selectedOrderIds
|
||||
.map((id) => _getStatusFromOrder(_ordersMap[id] ?? {}))
|
||||
.map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {}))
|
||||
.toSet();
|
||||
|
||||
if (statuses.length == 1 && statuses.first == 'ACCEPT') {
|
||||
@@ -1794,6 +1795,7 @@ class _HomepageState extends State<Homepage>
|
||||
_tripStops,
|
||||
collectedIds: _collectedIds,
|
||||
acceptedIds: _acceptedIds,
|
||||
pivotActions: _pivotActions,
|
||||
// ── The set the other screen was passing and this one was not ──
|
||||
//
|
||||
// Both screens call [WorkBoundary.deliveryQueue], which was supposed to be
|
||||
@@ -1911,17 +1913,29 @@ class _HomepageState extends State<Homepage>
|
||||
/// The middle ground between one-at-a-time and all-at-once: on a long trip
|
||||
/// he can scan the list, tick the four addresses he knows are open, and take
|
||||
/// them in a single action instead of eight taps.
|
||||
/// ── Both filters here were keyed on the undecided list ──
|
||||
///
|
||||
/// `_ordersMap.containsKey` dropped every already-accepted id before the
|
||||
/// action ran, and `_selectedOrders` was then rebuilt over `_orderIds` —
|
||||
/// also undecided-only — so an accepted id could not survive into
|
||||
/// [_handleBulkAction] by either route. The selection bar could accept and
|
||||
/// nothing else, whatever its label said.
|
||||
///
|
||||
/// Both now resolve through [_bulkStopFor], so a ticked set that has already
|
||||
/// been accepted arrives intact and the action advances it to arrived, and
|
||||
/// then to picked. Which rung it is remains [_handleBulkAction]'s decision,
|
||||
/// read off the stops themselves.
|
||||
Future<void> _acceptSelectedStops(List<String> orderIds) async {
|
||||
final ids = orderIds.where((id) => _ordersMap.containsKey(id)).toList();
|
||||
final ids = orderIds.where((id) => _bulkStopFor(id) != null).toList();
|
||||
if (ids.isEmpty || _acceptingTripId != null) return;
|
||||
|
||||
setState(() {
|
||||
_acceptingTripId = 'selection';
|
||||
_selectedOrders = {for (final id in _orderIds) id: ids.contains(id)};
|
||||
_selectedOrders = {for (final id in ids) id: true};
|
||||
});
|
||||
|
||||
try {
|
||||
await _handleBulkAction(navigateToBookings: false);
|
||||
await _handleBulkAction();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -2331,6 +2345,24 @@ class _HomepageState extends State<Homepage>
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The stop a bulk selection refers to, wherever it currently lives.
|
||||
///
|
||||
/// ── Why [_ordersMap] on its own could only ever accept ──
|
||||
///
|
||||
/// `_ordersMap` is built from `validOrders`, which is deliberately the
|
||||
/// *undecided* stops only — so a stop leaves it the moment it is accepted.
|
||||
/// Every rung past ACCEPT looked its stop up there, got null and `continue`d,
|
||||
/// and the label is derived from the same map: a selection of accepted stops
|
||||
/// read back as `ACCEPT`, so the button said **Accept Bookings** and posted
|
||||
/// another accept. `Move to Arrived` and `Move to Picked` were unreachable
|
||||
/// from the selection bar even though [_handleBulkAction] implements both.
|
||||
///
|
||||
/// [_allTripStops] keeps every stop in every state, which is exactly what the
|
||||
/// rungs past acceptance need. On a milk run the order stays on Home to be
|
||||
/// walked accepted → arrived → picked — see [MilkRun].
|
||||
Map<String, dynamic>? _bulkStopFor(String orderId) =>
|
||||
_ordersMap[orderId] ?? _stopById(orderId);
|
||||
|
||||
/// Walks every ticked stop up one rung, in one gesture.
|
||||
///
|
||||
/// This is the shape of the job at a kitchen: five parcels come off one
|
||||
@@ -2794,7 +2826,7 @@ class _HomepageState extends State<Homepage>
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
@@ -2835,7 +2867,7 @@ class _HomepageState extends State<Homepage>
|
||||
actionLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
@@ -2854,7 +2886,27 @@ class _HomepageState extends State<Homepage>
|
||||
/// checks each stop is still accepted before touching it.
|
||||
List<Map<String, dynamic>> _lastAcceptedBatch = const [];
|
||||
|
||||
Future<void> _handleBulkAction({bool navigateToBookings = true}) async {
|
||||
/// Walks the ticked stops up one rung: ACCEPT → ACCEPTED → ARRIVED → PICKED.
|
||||
///
|
||||
/// ── Where the rider ends up is the line's decision, not the caller's ──
|
||||
///
|
||||
/// This took a `navigateToBookings` flag, defaulted to `true`, and the two
|
||||
/// branches it selected disagreed about the thing [ServiceProfile] already
|
||||
/// answers. The tick-based path passed `false` and got the profile-aware
|
||||
/// branch; the bulk action button passed nothing, got the default, and hit an
|
||||
/// unconditional `Get.offAll(BottomPage(initialIndex: 1))`.
|
||||
///
|
||||
/// On a **milk run** that is the wrong move and it broke the whole ladder.
|
||||
/// The rider accepts at a kitchen, and the order has to stay on Home to be
|
||||
/// walked to arrived and then picked — see [MilkRun]. Throwing him at the
|
||||
/// work tab on accept sent him to a tab that was correctly empty, made the
|
||||
/// accept look like it had filed his order somewhere else, and left the two
|
||||
/// rungs that follow it unreachable from where he was standing.
|
||||
///
|
||||
/// So the flag is gone. `handsOffAtCollection` is the one rule and it is read
|
||||
/// in one place: the line that hands off at *acceptance* (logistics) moves
|
||||
/// him, the line that hands off at *collection* (milk man) keeps him on Home.
|
||||
Future<void> _handleBulkAction() async {
|
||||
final selectedOrderIds = _selectedOrders.entries
|
||||
.where((e) => e.value)
|
||||
.map((e) => e.key)
|
||||
@@ -2863,7 +2915,7 @@ class _HomepageState extends State<Homepage>
|
||||
if (selectedOrderIds.isEmpty) return;
|
||||
|
||||
final statuses = selectedOrderIds
|
||||
.map((id) => _getStatusFromOrder(_ordersMap[id] ?? {}))
|
||||
.map((id) => _getStatusFromOrder(_bulkStopFor(id) ?? const {}))
|
||||
.toSet();
|
||||
|
||||
String targetStatus;
|
||||
@@ -2984,8 +3036,15 @@ class _HomepageState extends State<Homepage>
|
||||
|
||||
final List<Map<String, dynamic>> acceptedBookings = [];
|
||||
|
||||
/// Orders this batch confirmed as collected, mirrored into [_collectedIds]
|
||||
/// once the loop is done so the cards re-read their leg immediately rather
|
||||
/// than at the next poll.
|
||||
final Set<String> bulkCollected = <String>{};
|
||||
|
||||
for (final orderId in selectedOrderIds) {
|
||||
final Booking = _ordersMap[orderId];
|
||||
// Not `_ordersMap` — a stop past ACCEPT is no longer in it, and every
|
||||
// rung after the first was silently skipped here. See [_bulkStopFor].
|
||||
final Booking = _bulkStopFor(orderId);
|
||||
if (Booking == null) continue;
|
||||
|
||||
final pickupId = int.tryParse('${Booking['pickupid'] ?? 0}') ?? 0;
|
||||
@@ -3071,12 +3130,45 @@ class _HomepageState extends State<Homepage>
|
||||
if (success) {
|
||||
_startPickupPosting(Booking);
|
||||
_hiddenOrderIds.add(orderId);
|
||||
// Picked → remove from the accepted store so it stops counting as
|
||||
// an open booking on the Bookings tab, and record it as finished
|
||||
// so it lands on Activity. Both stores, or the stop falls out of
|
||||
// the app entirely.
|
||||
await removeAcceptedBookings([orderId]);
|
||||
await addCompletedBookings([Booking]);
|
||||
|
||||
// ── The write that says the load is in his hands ──
|
||||
//
|
||||
// `_advanceStop` records this rung in the collected store and
|
||||
// this branch did not, so a bag picked from the **selection bar**
|
||||
// was never marked as carried. That one omission is what put
|
||||
// **Start Pickup** on a delivery: [MilkRun.workingKind] reads
|
||||
// exactly this set to decide which leg a stop is on, and the
|
||||
// booking adapter stamps `type: pickup` on every row, so with an
|
||||
// empty set a bag already in the box still described itself as a
|
||||
// collection. The same answer feeds `toDrop` on the queue's
|
||||
// distance, so the card also measured to the kitchen he had just
|
||||
// left instead of to the customer's door — which is the "km and
|
||||
// time are wrong" half of it.
|
||||
//
|
||||
// Recorded only after the backend confirmed, same rule as accept.
|
||||
await addCollectedOrderIds([orderId]);
|
||||
bulkCollected.add(orderId);
|
||||
// One copy of the day, and it is now stale — Deliveries must not
|
||||
// render this order from a response that predates the hand-over.
|
||||
unawaited(WorkRepository.instance.invalidate());
|
||||
|
||||
// ── Picked is a hand-over on one line and a finish on the other ──
|
||||
//
|
||||
// This branch closed the booking outright. That is right for
|
||||
// **logistics**, where what he collects goes to the hub and the
|
||||
// booking's own story ends at the counter.
|
||||
//
|
||||
// On a **milk run** it is wrong and it loses the order: collection
|
||||
// is the *middle* of the day — every bag is followed by a round of
|
||||
// drops he makes himself — so filing it as completed sent it to
|
||||
// Activity instead of to Deliveries, and the delivery leg had
|
||||
// nothing to work. See [ServiceProfile.handsOffAtCollection].
|
||||
if (ServiceProfile.active.handsOffAtCollection) {
|
||||
await addAcceptedBookings([Booking]);
|
||||
} else {
|
||||
await removeAcceptedBookings([orderId]);
|
||||
await addCompletedBookings([Booking]);
|
||||
}
|
||||
// Off the arrived rung — it has been collected.
|
||||
await removeArrivedOrderIds([orderId]);
|
||||
}
|
||||
@@ -3093,6 +3185,12 @@ class _HomepageState extends State<Homepage>
|
||||
}
|
||||
}
|
||||
|
||||
// The leg every card reads is derived from this set, so it has to land
|
||||
// before the next build — not at the next poll three seconds later.
|
||||
if (bulkCollected.isNotEmpty && mounted) {
|
||||
setState(() => _collectedIds = {..._collectedIds, ...bulkCollected});
|
||||
}
|
||||
|
||||
// Persist accepted bookings so the Bookings tab can show them and Home
|
||||
// keeps them filtered out.
|
||||
if (acceptedBookings.isNotEmpty) {
|
||||
@@ -3183,7 +3281,7 @@ class _HomepageState extends State<Homepage>
|
||||
// the spot rather than at the next tick of its poller, and the switch is an
|
||||
// IndexedStack index change — the same ScaffoldMessenger, so the UNDO
|
||||
// snackbar below travels with him and is still there to be pressed.
|
||||
if (bulkSucceeded && targetStatus == 'ACCEPTED' && !navigateToBookings) {
|
||||
if (bulkSucceeded && targetStatus == 'ACCEPTED') {
|
||||
final taken = _lastAcceptedBatch;
|
||||
final n = taken.length;
|
||||
|
||||
@@ -3234,12 +3332,6 @@ class _HomepageState extends State<Homepage>
|
||||
return;
|
||||
}
|
||||
|
||||
if (bulkSucceeded && targetStatus == 'ACCEPTED' && navigateToBookings) {
|
||||
AppFeedback.successGlobal(
|
||||
'Moved to your ${ServiceProfile.active.workTabLabel}',
|
||||
);
|
||||
Get.offAll(() => const BottomPage(initialIndex: 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole route in one object: load, distance, time windows, shift.
|
||||
@@ -3256,6 +3348,20 @@ class _HomepageState extends State<Homepage>
|
||||
riderLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||||
);
|
||||
|
||||
/// A trip with nothing in it, for a slot the hub has not filled.
|
||||
///
|
||||
/// Every figure the brief draws is derived from the stops, so an empty list
|
||||
/// gives `0 stops left`, a progress track at zero and an em dash under
|
||||
/// duration, distance and payment on its own — there is no "empty" variant of
|
||||
/// the brief to keep in step with the real one, because this *is* the real
|
||||
/// one with nothing in it.
|
||||
Trip _unassignedTrip() => Trip.fromStops(
|
||||
id: 'unassigned-$_selectedTripIndex',
|
||||
stops: const [],
|
||||
hubLat: _lastGeocodedPosition?.latitude ?? _riderLat,
|
||||
hubLng: _lastGeocodedPosition?.longitude ?? _riderLng,
|
||||
);
|
||||
|
||||
/// One configured [TripCard] for the selected trip.
|
||||
///
|
||||
/// Home does not render the card as a single widget any more: its summary, its
|
||||
@@ -3556,7 +3662,7 @@ class _HomepageState extends State<Homepage>
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.4,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -3564,13 +3670,13 @@ class _HomepageState extends State<Homepage>
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
'Stay on duty — new orders appear here the moment '
|
||||
'your office assigns them.',
|
||||
'Stay on duty new bookings appear here the moment '
|
||||
'your office assigns you.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -3601,7 +3707,7 @@ class _HomepageState extends State<Homepage>
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: _textPrimary,
|
||||
),
|
||||
@@ -3624,7 +3730,7 @@ class _HomepageState extends State<Homepage>
|
||||
"Go Online",
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -3717,7 +3823,7 @@ class _HomepageState extends State<Homepage>
|
||||
'${_getBulkButtonLabel()} (${selectedCount})',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.3,
|
||||
@@ -3763,7 +3869,7 @@ class _HomepageState extends State<Homepage>
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: _textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
@@ -3868,32 +3974,9 @@ class _HomepageState extends State<Homepage>
|
||||
// when it collapses, which is what a rider scrolling needs;
|
||||
// three tabs held permanently would be the same information
|
||||
// twice, in the space the work is supposed to be in.
|
||||
if (trips.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 2.h),
|
||||
child: TripSelector(
|
||||
trips: trips,
|
||||
selectedIndex: _selectedTripIndex,
|
||||
acceptedIds: _acceptedIds,
|
||||
rejectedIds: _rejectedIds,
|
||||
// Selection is per-trip, so the ticks do not travel
|
||||
// with the rider between trips — a tick made on
|
||||
// Trip 1 must not be counted by the bar while Trip 2
|
||||
// is on screen. See [_selectedPendingIds].
|
||||
onSelect: (index) {
|
||||
// His choice from here on, even if it is an empty
|
||||
// slot — see [_settleTripTab].
|
||||
_tripTabChosen = true;
|
||||
if (index == _selectedTripIndex) return;
|
||||
setState(() {
|
||||
_selectedTripIndex = index;
|
||||
_selectedStopIds.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// The tabs are no longer a sliver of their own. They are
|
||||
// the head of the panel that holds the trip, and that panel
|
||||
// is built with the work below — see the `else` branch.
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// READING ORDER
|
||||
@@ -3989,15 +4072,119 @@ class _HomepageState extends State<Homepage>
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
// ── The panel is the page ──
|
||||
//
|
||||
// Tabs and trip used to be two slivers: a control in one
|
||||
// box, the work it governs in the next, with nothing but
|
||||
// adjacency to say they were related. The panel encloses
|
||||
// the run now — the selected tab is the top edge of the
|
||||
// brand surface the brief, the route and the action bar all
|
||||
// sit inside, so which trip is on screen is said once, by
|
||||
// the shape holding it.
|
||||
//
|
||||
// Still not pinned. The header keeps
|
||||
// `Trip 1 · 17 left · 33/50` when it collapses, which is
|
||||
// what a rider scrolling needs; a tab strip held
|
||||
// permanently would be the same information twice, in the
|
||||
// space the work is supposed to be in.
|
||||
// ── The tabs are attached to the trip's figures ──
|
||||
//
|
||||
// Tabs and brief used to be two boxes: a control in one
|
||||
// sliver, the figures it governs in the next, with nothing
|
||||
// but adjacency to say they were related. They are one
|
||||
// panel now — the selected tab is the top edge of the glass
|
||||
// the brief sits on, so which trip is on screen is said
|
||||
// once, by the shape holding it.
|
||||
//
|
||||
// The route and the action bar stay outside it, on the
|
||||
// canvas with their own white panel. A version of this put
|
||||
// the whole page inside the panel and it was too much: the
|
||||
// ground a rider reads a route off should be the quietest
|
||||
// thing on the screen, not the loudest.
|
||||
//
|
||||
// Still not pinned. The header keeps
|
||||
// `Trip 1 · 17 left · 33/50` when it collapses, which is
|
||||
// what a rider scrolling needs; a tab strip held
|
||||
// permanently would be the same information twice, in the
|
||||
// space the work is supposed to be in.
|
||||
else ...[
|
||||
SliverToBoxAdapter(
|
||||
// Air above, and the panel takes the same gutter as
|
||||
// every other layer-1 surface on Home. The inset inside
|
||||
// it is [TripSelector.contentInset], so the brief's copy
|
||||
// lands on the same vertical as `TODAY'S RUN` below.
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
MilerSurface.panelGutter,
|
||||
12.h,
|
||||
MilerSurface.panelGutter,
|
||||
0,
|
||||
),
|
||||
child: TripSelector(
|
||||
trips: trips,
|
||||
selectedIndex: _selectedTripIndex,
|
||||
acceptedIds: _acceptedIds,
|
||||
rejectedIds: _rejectedIds,
|
||||
// Selection is per-trip, so the ticks do not travel
|
||||
// with the rider between trips — a tick made on
|
||||
// Trip 1 must not be counted by the bar while Trip 2
|
||||
// is on screen. See [_selectedPendingIds].
|
||||
onSelect: (index) {
|
||||
// His choice from here on, even if it is an empty
|
||||
// slot — see [_settleTripTab].
|
||||
_tripTabChosen = true;
|
||||
if (index == _selectedTripIndex) return;
|
||||
setState(() {
|
||||
_selectedTripIndex = index;
|
||||
_selectedStopIds.clear();
|
||||
});
|
||||
},
|
||||
// ── An unfilled slot gets the same panel ──
|
||||
//
|
||||
// It used to get a different one: a line of its own
|
||||
// reading `Not assigned yet`, which made the panel
|
||||
// a 32pt stub under a tab twice its height, and
|
||||
// changed the shape of the screen depending on
|
||||
// whether the hub had got to this slot yet.
|
||||
//
|
||||
// The brief of an empty slot is a real brief — it
|
||||
// is just all zeroes. `0 stops left`, an empty
|
||||
// track, and an em dash under every figure that has
|
||||
// nothing to report. So the panel is the same panel
|
||||
// at the same height, and when the hub assigns the
|
||||
// trip the figures fill in where the dashes were
|
||||
// instead of the page changing shape around the
|
||||
// rider.
|
||||
body: SmoothSwap(
|
||||
child: Reveal(
|
||||
// Keyed on the trip, so switching tabs plays
|
||||
// the entrance once and the 5s poll never
|
||||
// replays it. The empty slot keys on its own
|
||||
// index for the same reason.
|
||||
key: ValueKey(
|
||||
'brief_${trip?.id ?? 'unassigned-$_selectedTripIndex'}',
|
||||
),
|
||||
// The panel above is the surface and it is the
|
||||
// brand bar's red, so the brief drops its own
|
||||
// card and inverts its ink.
|
||||
child: _tripCardFor(
|
||||
trip ?? _unassignedTrip(),
|
||||
).buildSummary(onBrand: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// The route and the action bar, on the canvas.
|
||||
SliverToBoxAdapter(
|
||||
child: SmoothSwap(
|
||||
// ── A slot the hub has not filled ──
|
||||
//
|
||||
// The day has work, just not on this tab. Says so
|
||||
// by name rather than reusing the whole-day empty
|
||||
// state, which would claim he has nothing to do
|
||||
// while another trip is running.
|
||||
// The day has work, just not on this tab. Says so by
|
||||
// name rather than reusing the whole-day empty state,
|
||||
// which would claim he has nothing to do while another
|
||||
// trip is running.
|
||||
child: trip == null
|
||||
? KeyedSubtree(
|
||||
key: ValueKey(
|
||||
@@ -4008,37 +4195,21 @@ class _HomepageState extends State<Homepage>
|
||||
),
|
||||
)
|
||||
: Reveal(
|
||||
// Keyed on the trip, so switching tabs plays
|
||||
// the entrance once and the 5s poll never
|
||||
// replays it.
|
||||
key: ValueKey('reveal_${trip.id}'),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
// The brief sits UNDER the tabs, not above.
|
||||
//
|
||||
// It describes one trip, and the tabs are what
|
||||
// choose that trip — so above them it was
|
||||
// answering a question the rider had not been
|
||||
// asked yet, and switching tabs changed a
|
||||
// panel he had already scrolled past. Below
|
||||
// them it reads as the selected tab's own
|
||||
// content, which is what it is, and it lands
|
||||
// inside the per-trip [Reveal] so it swaps
|
||||
// with the route it belongs to.
|
||||
_tripCardFor(trip).buildSummary(),
|
||||
// The "one thing to do now" head that
|
||||
// used to sit here is gone. It drew the
|
||||
// current place, its load and a
|
||||
// full-width **Navigate to pickup** —
|
||||
// all of which the timeline's own group
|
||||
// node below already carries, button
|
||||
// included. Two identical red buttons
|
||||
// for one destination was the single
|
||||
// largest thing on this screen; the
|
||||
// node that owns the journey owns the
|
||||
// action.
|
||||
// full-width **Navigate to pickup** — all
|
||||
// of which the timeline's own group node
|
||||
// below already carries, button included.
|
||||
// Two identical red buttons for one
|
||||
// destination was the single largest
|
||||
// thing on this screen; the node that owns
|
||||
// the journey owns the action.
|
||||
_tripCardFor(trip).buildStops(),
|
||||
_tripCardFor(trip).buildActionBar(),
|
||||
],
|
||||
@@ -4046,6 +4217,7 @@ class _HomepageState extends State<Homepage>
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// ── Room for everything that floats over this list ──
|
||||
//
|
||||
@@ -4238,7 +4410,7 @@ Widget _buildDetailRow(String label, String value) {
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14.sp,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -4258,7 +4430,7 @@ Widget _buildDetailRow(String label, String value) {
|
||||
value.contains('Accepted')
|
||||
? primaryRed
|
||||
: ColorConstants.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14.sp,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
height: 1.35,
|
||||
@@ -4336,7 +4508,7 @@ void _showProductDetailsSheet(
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
|
||||
@@ -203,7 +203,7 @@ class _Card extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -232,7 +232,7 @@ class _Card extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -323,7 +323,7 @@ class _LiveChip extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.7,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
|
||||
@@ -153,8 +153,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
/// route card behind this sheet — the card had no address at all while this
|
||||
/// one did, and a counter that is named on the sheet and anonymous on the
|
||||
/// card is the same payload answering two different questions.
|
||||
String get _address =>
|
||||
lines.isEmpty ? '' : pickupAddressOf(lines.first.stop);
|
||||
String get _address => lines.isEmpty ? '' : pickupAddressOf(lines.first.stop);
|
||||
|
||||
static double _d(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
@@ -228,7 +227,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.body.copyWith(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.15,
|
||||
color: Narrative.ink,
|
||||
@@ -340,7 +339,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.body.copyWith(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Narrative.ink,
|
||||
),
|
||||
),
|
||||
@@ -427,7 +426,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.body.copyWith(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -457,7 +456,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
@@ -476,7 +475,7 @@ class PickupPreviewSheet extends StatelessWidget {
|
||||
OrderManifest.countLabel(lines.length),
|
||||
style: MilerType.micro.copyWith(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Narrative.inkMuted,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -363,7 +363,7 @@ class ShiftBanner extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: ColorConstants.slateText,
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -378,7 +378,7 @@ class ShiftBanner extends StatelessWidget {
|
||||
? ColorConstants.warning
|
||||
: ColorConstants.secondaryText),
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/data/stop_area.dart';
|
||||
import 'package:miler/views/Dashboard/home/stop_card.dart' show LiveMark;
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
@@ -209,9 +210,7 @@ class RouteGroup {
|
||||
if (allStopStates.length != allStops.length) {
|
||||
return allStops.isNotEmpty ? allStops.length : stops.length;
|
||||
}
|
||||
final carried = allStopStates
|
||||
.where((s) => s == StopState.collected)
|
||||
.length;
|
||||
final carried = allStopStates.where((s) => s == StopState.collected).length;
|
||||
return stops.length + carried;
|
||||
}
|
||||
|
||||
@@ -495,14 +494,32 @@ class _RouteTimelineState extends State<RouteTimeline> {
|
||||
}
|
||||
|
||||
/// Every order on the run the rider may tick right now, across the flat
|
||||
/// groups — a parcel route, where each stop is its own place.
|
||||
/// groups — the stops that are their own place.
|
||||
///
|
||||
/// One rung only, and only when there are at least two: with one stop its own
|
||||
/// row already is the select-all.
|
||||
///
|
||||
/// ── Why this is not "every group is flat" ──
|
||||
///
|
||||
/// It was, and the guard was right while a route was one shape or the other:
|
||||
/// a parcel run of single-stop nodes got a run-level select-all, a kitchen
|
||||
/// run got one per counter, and neither had the other's.
|
||||
///
|
||||
/// A logistics route is now **both** — its drops gather under the hub they
|
||||
/// were loaded at, its collections stay their own doors — and `every` made
|
||||
/// the whole control vanish the moment one group was not flat. The rider lost
|
||||
/// the only way to take his collections in one go, on exactly the routes that
|
||||
/// have the most of them.
|
||||
///
|
||||
/// So it gathers from the flat groups and ignores the rest. A grouped place
|
||||
/// already carries its own select-all *inside* it, over its own orders, which
|
||||
/// is the control that belongs to it; this one is for the stops that have no
|
||||
/// place above them to hold it. A run with no flat groups at all — a milk
|
||||
/// round — still gets nothing here, exactly as before.
|
||||
List<String> get _flatSelectIds {
|
||||
if (!widget.groups.every((g) => g.flat)) return const [];
|
||||
final ids = <String>[];
|
||||
for (final g in widget.groups) {
|
||||
if (!g.flat) continue;
|
||||
if (g.state != StopState.pending) continue;
|
||||
for (final (i, s) in g.stops.indexed) {
|
||||
if (g.stopStates[i] != StopState.pending) continue;
|
||||
@@ -783,7 +800,7 @@ class SelectAllAction extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: scoped ? TextAlign.right : TextAlign.left,
|
||||
style: MilerType.label.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
@@ -1006,7 +1023,7 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.label.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
@@ -1488,7 +1505,7 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
? MilerType.cardTitle
|
||||
: MilerType.body)
|
||||
.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: group.isDone || rejected
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.slateText,
|
||||
@@ -1571,7 +1588,7 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// pale grey reserved for things nobody has
|
||||
// to act on.
|
||||
style: MilerType.label.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: chosen > 0
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
@@ -1608,7 +1625,18 @@ class PickupTimelineGroup extends StatelessWidget {
|
||||
// the distances line up on one right edge, so "which is
|
||||
// nearest" is answered by looking down a column rather
|
||||
// than reading five blocks.
|
||||
// ── And on a flat stop, which has no rail under it ──
|
||||
//
|
||||
// A place with one order draws no list, so there is no
|
||||
// rung for [_KindTag] to sit on — and a logistics route
|
||||
// is mostly flat stops, which is exactly where "am I
|
||||
// collecting or dropping here" is asked. The tag goes on
|
||||
// the header instead, in the same corner and at the same
|
||||
// size, so the answer is in one place whichever shape the
|
||||
// route happens to take.
|
||||
if (group.flat) ...[
|
||||
if (group.stops.isNotEmpty)
|
||||
_KindTag(kind: stopKindOf(group.stops.first)),
|
||||
SizedBox(width: 4.w),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
@@ -2151,8 +2179,8 @@ class PickupTimelineStep extends StatelessWidget {
|
||||
style: MilerType.body.copyWith(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: selected || accepted
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
color: rejected
|
||||
? ColorConstants.secondaryText
|
||||
: selected || accepted
|
||||
@@ -2190,6 +2218,11 @@ class PickupTimelineStep extends StatelessWidget {
|
||||
// [OrderManifest.labelFor].
|
||||
label: rejected ? 'Rejected' : line.label,
|
||||
orderId: line.orderId,
|
||||
// What the rider is doing at this door — see
|
||||
// [_KindTag]. Read from the stop itself, so a route
|
||||
// that mixes collections and drops says which is which
|
||||
// on every rung.
|
||||
kind: stopKindOf(line.stop),
|
||||
selected: selected,
|
||||
onTap: canOpen ? onOpen : null,
|
||||
),
|
||||
@@ -2228,12 +2261,17 @@ class _OrderOpenBlock extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
final String orderId;
|
||||
|
||||
/// Collection, drop, or both — stamped beside the chevron.
|
||||
final StopKind kind;
|
||||
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _OrderOpenBlock({
|
||||
required this.label,
|
||||
required this.orderId,
|
||||
required this.kind,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
@@ -2310,8 +2348,8 @@ class _OrderOpenBlock extends StatelessWidget {
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
? FontWeight.w800
|
||||
: FontWeight.w700,
|
||||
color: selected
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.secondaryText,
|
||||
@@ -2327,12 +2365,22 @@ class _OrderOpenBlock extends StatelessWidget {
|
||||
// the list. The id is on the detail sheet this block opens, and
|
||||
// on the Activity record — both places the rare question is
|
||||
// actually asked.
|
||||
if (onTap != null)
|
||||
// ── What he is doing here, in one or two characters ──
|
||||
//
|
||||
// A mixed route runs collections and drops down the same rail,
|
||||
// and until now the only way to tell one rung from another was
|
||||
// to open it. The tag sits inside the block the chevron closes,
|
||||
// so the row reads `name · area · P ›` — identity, place, job,
|
||||
// and the way in.
|
||||
_KindTag(kind: kind),
|
||||
if (onTap != null) ...[
|
||||
SizedBox(width: 4.w),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18.sp,
|
||||
color: ColorConstants.borderStrong,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -2342,6 +2390,60 @@ class _OrderOpenBlock extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// `P`, `D` or `P&D` on a rail rung.
|
||||
///
|
||||
/// ── A mark, not a word ──
|
||||
///
|
||||
/// The full names are [StopKindUi.badgeLabel], and they are what the stop's own
|
||||
/// card and sheet carry — there is room there, and the rider is reading one
|
||||
/// stop. The rail is a *column*: he runs down it deciding which doors are
|
||||
/// collections and which are drops, and `PICKUP & DELIVERY` at a size that fits
|
||||
/// beside a chevron is a word he has to read on every rung. One or two
|
||||
/// characters is a mark he recognises. See [StopKindUi.shortLabel].
|
||||
///
|
||||
/// ── And it is grey ──
|
||||
///
|
||||
/// The obvious version tints it with the kind's own accent — collections one
|
||||
/// colour, drops another. `stop_card_neutral_test.dart` forbids exactly that,
|
||||
/// and the reason survives this tag: the route carries **one** accent, the
|
||||
/// brand, on the live node and the journey action, and a per-stop hue down the
|
||||
/// rail competes with the one mark that says *where you are*. Three hues on
|
||||
/// every rung is a second colour system on the screen that most needs one.
|
||||
///
|
||||
/// What was asked for here is the *information* — which of these is a
|
||||
/// collection and which is a drop — and a letter carries that on its own. So
|
||||
/// the tag is the same quiet grey as the metric tiles, and the accents stay
|
||||
/// where that test put them: on the stop's own sheet, one tap away, where the
|
||||
/// rider is reading one stop instead of scanning forty.
|
||||
class _KindTag extends StatelessWidget {
|
||||
final StopKind kind;
|
||||
|
||||
const _KindTag({required this.kind});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(left: 6.w),
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.tint(ColorConstants.slateText, 0.055),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Text(
|
||||
kind.shortLabel,
|
||||
maxLines: 1,
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 10.5.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.1,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The order id, as every screen reads it.
|
||||
String stopIdOf(Map<String, dynamic> stop) =>
|
||||
(stop['orderid'] ?? '').toString();
|
||||
@@ -2893,7 +2995,7 @@ class _JourneyColumn extends StatelessWidget {
|
||||
// it: same column, one step down in size and weight.
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: prominent ? 13.sp : 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
),
|
||||
@@ -2981,7 +3083,7 @@ class _StateWord extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.label.copyWith(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
@@ -3182,7 +3284,7 @@ class _TextAction extends StatelessWidget {
|
||||
Text(
|
||||
label,
|
||||
style: MilerType.label.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -245,7 +245,7 @@ class SelectionBar extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
|
||||
@@ -219,7 +219,10 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
(String, bool)? get _instruction {
|
||||
if (_isArrival || widget.lines.isEmpty) return null;
|
||||
if (!_canSlide) {
|
||||
return ('Nothing was handed over — call your office before you leave.', true);
|
||||
return (
|
||||
'Nothing was handed over — call your office before you leave.',
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (_missing.isEmpty) {
|
||||
return (
|
||||
@@ -317,7 +320,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
color: ColorConstants.slateText,
|
||||
@@ -329,7 +332,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
_subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -345,7 +348,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
color: exceptional
|
||||
? ColorConstants.warning
|
||||
@@ -429,7 +432,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
'Report a missing order',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.errorRed,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -447,7 +450,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
: '${_missing.length} marked missing',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -464,7 +467,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -559,7 +562,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
'${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -575,7 +578,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: gone
|
||||
? ColorConstants.secondaryText
|
||||
@@ -598,30 +601,33 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
// only on the exception, which is the one time it carries a
|
||||
// fact the row does not already state.
|
||||
if (gone || line.label.isNotEmpty)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: gone
|
||||
? ColorConstants.errorRed.withValues(alpha: 0.08)
|
||||
: MilerSurface.working,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 7.w,
|
||||
vertical: 3.h,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
gone ? 'Missing' : line.label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
decoration: BoxDecoration(
|
||||
color: gone
|
||||
? ColorConstants.errorRed
|
||||
: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
? ColorConstants.errorRed.withValues(alpha: 0.08)
|
||||
: MilerSurface.working,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
gone ? 'Missing' : line.label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: gone
|
||||
? ColorConstants.errorRed
|
||||
: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -668,7 +674,7 @@ class _StopActionSheetState extends State<StopActionSheet> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -783,7 +789,7 @@ class _CratePhotoRow extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.1,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: taken
|
||||
@@ -800,7 +806,7 @@ class _CratePhotoRow extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
|
||||
@@ -208,7 +208,7 @@ class StopCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 21.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.12,
|
||||
letterSpacing: -0.6,
|
||||
color: ColorConstants.slateText,
|
||||
@@ -227,7 +227,7 @@ class StopCard extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -290,7 +290,7 @@ class StopCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 1.0,
|
||||
// A mark, in the type's own colour — never a fill and never a
|
||||
// border. See `stop_card_neutral_test`, which pins exactly that.
|
||||
@@ -336,7 +336,7 @@ class StopCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -387,7 +387,7 @@ class StopCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -518,7 +518,7 @@ class StopCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -629,7 +629,7 @@ class _LabelChip extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.1,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -819,7 +819,7 @@ class _LiveMarkState extends State<LiveMark>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.2,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -896,7 +896,7 @@ class StopStateChip extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: filled ? 12.5.sp : 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0.3,
|
||||
color: look.color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -953,7 +953,7 @@ class _MetaLine extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
TextStyle style(Color c) => TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: c,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
|
||||
@@ -202,8 +202,12 @@ class StopDetailSheet extends StatelessWidget {
|
||||
// What the distance is measured to, and what the map is centred on.
|
||||
final drop = pinIsDrop;
|
||||
|
||||
final lat = pinIsDrop ? dropLat : _d(stop['pickuplat'] ?? stop['PickupLat']);
|
||||
final lng = pinIsDrop ? dropLng : _d(stop['pickuplon'] ?? stop['PickupLon']);
|
||||
final lat = pinIsDrop
|
||||
? dropLat
|
||||
: _d(stop['pickuplat'] ?? stop['PickupLat']);
|
||||
final lng = pinIsDrop
|
||||
? dropLng
|
||||
: _d(stop['pickuplon'] ?? stop['PickupLon']);
|
||||
final hasLocation = lat != 0 && lng != 0;
|
||||
|
||||
final name = _s(['pickupcustomer', 'tenantname']);
|
||||
@@ -266,114 +270,114 @@ class StopDetailSheet extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_header(kind, name),
|
||||
SizedBox(height: 14.h),
|
||||
if (hasLocation) ...[
|
||||
// ── The leg is stated once ──
|
||||
//
|
||||
// It was stated twice, twenty points apart: a strip across
|
||||
// the foot of the map, and a DISTANCE / EST. ARRIVAL pair
|
||||
// under it. Two readings of one fact, and the second cost
|
||||
// ~70pt on a sheet a rider already has to scroll — which is
|
||||
// the whole reason it felt long.
|
||||
//
|
||||
// The strip stays, because it is written on the picture of
|
||||
// the leg it describes, and it carries both halves: how far
|
||||
// and how long.
|
||||
_map(lat, lng, kind, meters),
|
||||
SizedBox(height: 18.h),
|
||||
],
|
||||
if (address.isNotEmpty) ...[
|
||||
_sectionLabel(addressLabel),
|
||||
SizedBox(height: 8.h),
|
||||
// ── The address is a block, not a paragraph ──
|
||||
//
|
||||
// It was loose text on the sheet's own white, at the same
|
||||
// weight as everything around it, and a full Indian address
|
||||
// is five lines long — so the thing a rider came here to
|
||||
// read had nothing holding it. On a tonal block it is one
|
||||
// object the eye can land on, and it is plainly the answer
|
||||
// to the label above it.
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 13.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Narrative.inset,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
// The full address, unabridged. The card above compresses
|
||||
// it to two lines; this is where the rest lives.
|
||||
child: SelectableText(
|
||||
address,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Narrative.ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
_header(kind, name),
|
||||
SizedBox(height: 14.h),
|
||||
if (hasLocation) ...[
|
||||
// ── The leg is stated once ──
|
||||
//
|
||||
// It was stated twice, twenty points apart: a strip across
|
||||
// the foot of the map, and a DISTANCE / EST. ARRIVAL pair
|
||||
// under it. Two readings of one fact, and the second cost
|
||||
// ~70pt on a sheet a rider already has to scroll — which is
|
||||
// the whole reason it felt long.
|
||||
//
|
||||
// The strip stays, because it is written on the picture of
|
||||
// the leg it describes, and it carries both halves: how far
|
||||
// and how long.
|
||||
_map(lat, lng, kind, meters),
|
||||
SizedBox(height: 18.h),
|
||||
],
|
||||
if (address.isNotEmpty) ...[
|
||||
_sectionLabel(addressLabel),
|
||||
SizedBox(height: 8.h),
|
||||
// ── The address is a block, not a paragraph ──
|
||||
//
|
||||
// It was loose text on the sheet's own white, at the same
|
||||
// weight as everything around it, and a full Indian address
|
||||
// is five lines long — so the thing a rider came here to
|
||||
// read had nothing holding it. On a tonal block it is one
|
||||
// object the eye can land on, and it is plainly the answer
|
||||
// to the label above it.
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 13.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Narrative.inset,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusLg,
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
],
|
||||
_sectionLabel('TASK DETAILS'),
|
||||
SizedBox(height: 8.h),
|
||||
// ── The call sits on the job, not at the foot of the sheet ──
|
||||
),
|
||||
// The full address, unabridged. The card above compresses
|
||||
// it to two lines; this is where the rest lives.
|
||||
child: SelectableText(
|
||||
address,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Narrative.ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
],
|
||||
_sectionLabel('TASK DETAILS'),
|
||||
SizedBox(height: 8.h),
|
||||
// ── The call sits on the job, not at the foot of the sheet ──
|
||||
//
|
||||
// Ringing the customer is not a step of the hand-over, it is
|
||||
// what you do *about* the hand-over — a wrong door, a gate
|
||||
// code, "I'm outside". As a full-width labelled button at the
|
||||
// bottom it took a third of the sheet's action area and read
|
||||
// as an alternative to delivering. As a disc on the row that
|
||||
// names the job it is exactly what it is: one tap, beside the
|
||||
// person it calls.
|
||||
if (kind.hasDelivery)
|
||||
_actionRow(
|
||||
icon: LucideIcons.truck,
|
||||
color: Narrative.burgundy,
|
||||
title: _countLabel('Deliver', deliver),
|
||||
// ── No OTP is asked for at the door ──
|
||||
//
|
||||
// Ringing the customer is not a step of the hand-over, it is
|
||||
// what you do *about* the hand-over — a wrong door, a gate
|
||||
// code, "I'm outside". As a full-width labelled button at the
|
||||
// bottom it took a third of the sheet's action area and read
|
||||
// as an alternative to delivering. As a disc on the row that
|
||||
// names the job it is exactly what it is: one tap, beside the
|
||||
// person it calls.
|
||||
if (kind.hasDelivery)
|
||||
_actionRow(
|
||||
icon: LucideIcons.truck,
|
||||
color: Narrative.burgundy,
|
||||
title: _countLabel('Deliver', deliver),
|
||||
// ── No OTP is asked for at the door ──
|
||||
//
|
||||
// Nothing issues a delivery code on this backend — the
|
||||
// `deliver` endpoint has no OTP column and cannot check
|
||||
// one (see [MilerApi.deliver]) — so the sheet was
|
||||
// instructing riders to collect a number that does not
|
||||
// exist and that nobody would verify. The proof is the
|
||||
// photo of the parcel, and now that is all it says.
|
||||
detail: 'Photo of the parcel',
|
||||
trailing: phone.isEmpty
|
||||
? null
|
||||
: _CallDisc(onTap: () => _dial(phone)),
|
||||
),
|
||||
if (kind.hasPickup)
|
||||
_actionRow(
|
||||
icon: LucideIcons.package,
|
||||
color: Narrative.burgundy,
|
||||
title: _countLabel('Collect', collect),
|
||||
detail: cash > 0 ? 'Collect ₹${cash.round()}' : 'Photo',
|
||||
trailing: (phone.isEmpty || kind.hasDelivery)
|
||||
? null
|
||||
: _CallDisc(onTap: () => _dial(phone)),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
_detailGrid(),
|
||||
if (notes.isNotEmpty) ...[
|
||||
SizedBox(height: 16.h),
|
||||
_noteBox(notes),
|
||||
],
|
||||
// ── The sheet has no action bar ──
|
||||
//
|
||||
// It closed with **Call** beside **Navigate to customer**,
|
||||
// and neither belonged. This is the *reference* surface —
|
||||
// what is at this stop, where exactly, what it weighs, who
|
||||
// to ring — opened from a card that already carries the
|
||||
// stop's own action. Navigation is the map screen's whole
|
||||
// job, and calling is now a disc on the row above.
|
||||
// Nothing issues a delivery code on this backend — the
|
||||
// `deliver` endpoint has no OTP column and cannot check
|
||||
// one (see [MilerApi.deliver]) — so the sheet was
|
||||
// instructing riders to collect a number that does not
|
||||
// exist and that nobody would verify. The proof is the
|
||||
// photo of the parcel, and now that is all it says.
|
||||
detail: 'Photo of the parcel',
|
||||
trailing: phone.isEmpty
|
||||
? null
|
||||
: _CallDisc(onTap: () => _dial(phone)),
|
||||
),
|
||||
if (kind.hasPickup)
|
||||
_actionRow(
|
||||
icon: LucideIcons.package,
|
||||
color: Narrative.burgundy,
|
||||
title: _countLabel('Collect', collect),
|
||||
detail: cash > 0 ? 'Collect ₹${cash.round()}' : 'Photo',
|
||||
trailing: (phone.isEmpty || kind.hasDelivery)
|
||||
? null
|
||||
: _CallDisc(onTap: () => _dial(phone)),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
_detailGrid(),
|
||||
if (notes.isNotEmpty) ...[
|
||||
SizedBox(height: 16.h),
|
||||
_noteBox(notes),
|
||||
],
|
||||
// ── The sheet has no action bar ──
|
||||
//
|
||||
// It closed with **Call** beside **Navigate to customer**,
|
||||
// and neither belonged. This is the *reference* surface —
|
||||
// what is at this stop, where exactly, what it weighs, who
|
||||
// to ring — opened from a card that already carries the
|
||||
// stop's own action. Navigation is the map screen's whole
|
||||
// job, and calling is now a disc on the row above.
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -416,7 +420,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
'$stopNumber',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Narrative.burgundy,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -441,7 +445,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.15,
|
||||
color: Narrative.ink,
|
||||
@@ -463,7 +467,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Narrative.inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -582,7 +586,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Narrative.ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -596,7 +600,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Narrative.inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -615,7 +619,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.8,
|
||||
color: Narrative.inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -678,7 +682,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Narrative.ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -701,7 +705,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
// read as something to tap, and it is a statement.
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Narrative.inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -815,7 +819,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.9,
|
||||
color: Narrative.inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -839,7 +843,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: Narrative.ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -854,7 +858,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: Narrative.ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -890,7 +894,7 @@ class StopDetailSheet extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onErrorContainer,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
|
||||
@@ -482,14 +482,13 @@ class Trip {
|
||||
/// `eta` is read last and usually answers nothing — on the v1 adapter it is
|
||||
/// a duration, not a clock, and [_parseTimestamp] rejects it rather than
|
||||
/// inventing a 15th of the month out of "15 min".
|
||||
static DateTime? dayPartTimeOf(Map<String, dynamic> stop) =>
|
||||
_parseTimestamp(
|
||||
stop['expected_pickup_time'] ??
|
||||
stop['expectedpickuptime'] ??
|
||||
stop['assignedat'] ??
|
||||
stop['assignedon'] ??
|
||||
stop['eta'],
|
||||
);
|
||||
static DateTime? dayPartTimeOf(Map<String, dynamic> stop) => _parseTimestamp(
|
||||
stop['expected_pickup_time'] ??
|
||||
stop['expectedpickuptime'] ??
|
||||
stop['assignedat'] ??
|
||||
stop['assignedon'] ??
|
||||
stop['eta'],
|
||||
);
|
||||
|
||||
/// Address components every stop on this trip shares — city, state,
|
||||
/// pincode and so on. Cached per build by the caller.
|
||||
|
||||
@@ -92,12 +92,26 @@ class TripBriefStrip extends StatefulWidget {
|
||||
/// Opens the whole route on one map. Hidden when the trip has no stops.
|
||||
final VoidCallback? onViewRoute;
|
||||
|
||||
/// This brief is drawn on the brand, inside Home's trip tab panel.
|
||||
///
|
||||
/// It changes two things together, because they are one decision. The white
|
||||
/// panel comes off — the tabs are a surface in their own right and a card
|
||||
/// inside a card is the nested-surface problem this file has been through
|
||||
/// twice — and every ink in here inverts, because slate on the brand bar's
|
||||
/// red is unreadable.
|
||||
///
|
||||
/// One flag rather than two: a bare brief that kept its dark ink, or a white
|
||||
/// brief that kept its panel, are both broken states nobody should be able to
|
||||
/// ask for. See [TripSelector].
|
||||
final bool onBrand;
|
||||
|
||||
const TripBriefStrip({
|
||||
super.key,
|
||||
required this.trip,
|
||||
required this.brief,
|
||||
required this.percent,
|
||||
this.onViewRoute,
|
||||
this.onBrand = false,
|
||||
Trip? remaining,
|
||||
}) : remaining = remaining ?? trip;
|
||||
|
||||
@@ -141,6 +155,40 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
int get percent => widget.percent;
|
||||
VoidCallback? get onViewRoute => widget.onViewRoute;
|
||||
|
||||
// ── The ink follows the ground ──
|
||||
//
|
||||
// On the canvas the brief is slate on white. Inside Home's trip panel it is
|
||||
// white on the brand bar's red. Every colour in this file that is not a
|
||||
// *meaning* — not the green of money, not the amber of a warning — goes
|
||||
// through one of these, so the two states cannot drift apart one `Text` at a
|
||||
// time.
|
||||
bool get _onBrand => widget.onBrand;
|
||||
|
||||
/// Figures, headings, anything the rider reads first.
|
||||
Color get _ink => _onBrand ? Colors.white : ColorConstants.slateText;
|
||||
|
||||
/// Captions and labels: present, but not competing with [_ink].
|
||||
Color get _inkMuted => _onBrand
|
||||
? Colors.white.withValues(alpha: 0.74)
|
||||
: ColorConstants.secondaryText;
|
||||
|
||||
/// A rule between bands.
|
||||
Color get _hairline => _onBrand
|
||||
? Colors.white.withValues(alpha: 0.20)
|
||||
: ColorConstants.borderSubtle;
|
||||
|
||||
/// The quiet tile behind a metric's glyph.
|
||||
Color get _tileFill => _onBrand
|
||||
? Colors.white.withValues(alpha: 0.14)
|
||||
: ColorConstants.tint(ColorConstants.slateText, 0.055);
|
||||
|
||||
/// Money, and the completion green. Both are meanings rather than decoration,
|
||||
/// so on the canvas they keep their own colour — but green on red is the one
|
||||
/// pairing that fails outright, so on the brand they defer to white and let
|
||||
/// the words carry it.
|
||||
Color get _moneyInk => _onBrand ? Colors.white : ColorConstants.moneyGreen;
|
||||
Color get _doneInk => _onBrand ? Colors.white : ColorConstants.acceptGreen;
|
||||
|
||||
/// Stops the rider has finished, derived from the percentage the page already
|
||||
/// computed rather than recounting the sets here.
|
||||
int get _done => (_trip.stopCount * percent / 100).round();
|
||||
@@ -159,6 +207,16 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// sit"; measured, they were four points apart. Both sections now take
|
||||
// their edge from `MilerSurface.panelGutter` + `panelPad`, so they cannot
|
||||
// disagree — and `home_gutter_test` fails if they ever do.
|
||||
// ── Unless the caller is already the surface ──
|
||||
//
|
||||
// Home's trip tabs draw a glass panel and attach themselves to its top
|
||||
// edge; the brief is what is inside it. A [MilerPanel] in there would be a
|
||||
// white card floating in a tinted one, which is the nested-surface problem
|
||||
// this file has been through twice. So the caller says which it wants, the
|
||||
// gutter argument below still holds for everyone who wants the panel, and
|
||||
// the tabs supply their own inset. See [TripSelector.briefInset].
|
||||
if (widget.onBrand) return _column();
|
||||
|
||||
return MilerPanel(
|
||||
// ── Not the bordered card that was removed from here ──
|
||||
//
|
||||
@@ -171,43 +229,48 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// off it at 1.290 : 1, which is a surface rather than a hairline — so
|
||||
// this returns as a *fill with no border*, which is the thing the old
|
||||
// box was only pretending to be.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
expanded: _open,
|
||||
label: _open ? 'Hide trip figures' : 'Show trip figures',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
child: _head(),
|
||||
),
|
||||
child: _column(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The brief itself, with no surface of its own.
|
||||
Widget _column() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Semantics(
|
||||
button: true,
|
||||
expanded: _open,
|
||||
label: _open ? 'Hide trip figures' : 'Show trip figures',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
child: _head(),
|
||||
),
|
||||
// ── What the chevron actually controls ──
|
||||
//
|
||||
// The four figures were moved onto the head for a while, so they were
|
||||
// always drawn — and the chevron above them went on rotating while
|
||||
// nothing under it moved. A disclosure that does not disclose is worse
|
||||
// than no disclosure: the rider presses it, the arrow turns, the card
|
||||
// stays the same size, and he learns the control is broken.
|
||||
//
|
||||
// They are back under the fold and the fold **opens by default**, so
|
||||
// the card still arrives showing them. The difference is that closing
|
||||
// it now closes them, which is what the arrow has been promising.
|
||||
ClipRect(
|
||||
child: AnimatedSize(
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _open
|
||||
? _body()
|
||||
: const SizedBox(width: double.infinity, height: 0),
|
||||
),
|
||||
),
|
||||
// ── What the chevron actually controls ──
|
||||
//
|
||||
// The four figures were moved onto the head for a while, so they were
|
||||
// always drawn — and the chevron above them went on rotating while
|
||||
// nothing under it moved. A disclosure that does not disclose is worse
|
||||
// than no disclosure: the rider presses it, the arrow turns, the card
|
||||
// stays the same size, and he learns the control is broken.
|
||||
//
|
||||
// They are back under the fold and the fold **opens by default**, so
|
||||
// the card still arrives showing them. The difference is that closing
|
||||
// it now closes them, which is what the arrow has been promising.
|
||||
ClipRect(
|
||||
child: AnimatedSize(
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _open
|
||||
? _body()
|
||||
: const SizedBox(width: double.infinity, height: 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,11 +344,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (complete) ...[
|
||||
Icon(
|
||||
LucideIcons.circleCheck,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
Icon(LucideIcons.circleCheck, size: 22.sp, color: _doneInk),
|
||||
SizedBox(width: 8.w),
|
||||
// ── Expanded, not Flexible-plus-Spacer ──
|
||||
//
|
||||
@@ -305,9 +364,9 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -0.5,
|
||||
color: ColorConstants.acceptGreen,
|
||||
color: _doneInk,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -333,7 +392,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.4,
|
||||
// ── 1.2 → 1.0 ──
|
||||
//
|
||||
@@ -349,7 +408,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
// are the gaps either side of it.
|
||||
height: 1.0,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: ColorConstants.slateText,
|
||||
color: _ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -364,7 +423,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
child: Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
color: _inkMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -382,7 +441,12 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
child: Container(
|
||||
height: 6.h,
|
||||
width: double.infinity,
|
||||
color: MilerSurface.canvas,
|
||||
// On the brand the canvas grey is darker than the ground it
|
||||
// sits on, so an empty track reads as a filled one. White at a
|
||||
// fifth is the same idea the other way up.
|
||||
color: _onBrand
|
||||
? Colors.white.withValues(alpha: 0.22)
|
||||
: MilerSurface.canvas,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedFractionallySizedBox(
|
||||
duration: DesignConstants.motionState,
|
||||
@@ -390,7 +454,10 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
widthFactor: pct,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: complete
|
||||
// Brand red on the brand bar is a fill you cannot see.
|
||||
color: _onBrand
|
||||
? Colors.white
|
||||
: complete
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.primary,
|
||||
borderRadius: BorderRadius.circular(
|
||||
@@ -435,7 +502,12 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
//
|
||||
// Nothing is lost. Open the card and both figures are there, stated
|
||||
// properly, with the parcels and the cash beside them.
|
||||
Row(children: [const Spacer(), Flexible(child: _timeLeftLine())]),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Flexible(child: _timeLeftLine()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -488,7 +560,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -508,9 +580,9 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.moneyGreen,
|
||||
color: _moneyInk,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
);
|
||||
@@ -551,7 +623,7 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Divider(height: 1, color: ColorConstants.borderSubtle),
|
||||
Divider(height: 1, color: _hairline),
|
||||
SizedBox(height: 14.h),
|
||||
|
||||
// ── Band 1: the shift window ──
|
||||
@@ -572,10 +644,10 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
icon: LucideIcons.calendarCheck,
|
||||
label: 'Shift',
|
||||
value: _brief.shiftWindowLabel,
|
||||
tint: ColorConstants.slateText,
|
||||
tint: _ink,
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
Divider(height: 1, color: ColorConstants.borderSubtle),
|
||||
Divider(height: 1, color: _hairline),
|
||||
SizedBox(height: 14.h),
|
||||
],
|
||||
|
||||
@@ -685,14 +757,10 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
height: 34.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.tint(ColorConstants.slateText, 0.055),
|
||||
color: _tileFill,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(
|
||||
cell.$1,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
child: Icon(cell.$1, size: 16.sp, color: _inkMuted),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
@@ -707,8 +775,8 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -722,12 +790,10 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.15,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: cell.$4
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.slateText,
|
||||
color: cell.$4 ? _inkMuted : _ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -755,7 +821,9 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
height: 30.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: 0.09),
|
||||
color: _onBrand
|
||||
? Colors.white.withValues(alpha: 0.14)
|
||||
: tint.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(icon, size: 16.sp, color: tint),
|
||||
@@ -765,8 +833,8 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _inkMuted,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -779,9 +847,9 @@ class _TripBriefStripState extends State<TripBriefStrip> {
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
color: _ink,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/data/order_manifest.dart';
|
||||
import 'package:miler/data/stop_area.dart';
|
||||
import 'package:miler/data/milk_run.dart';
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -251,7 +252,11 @@ class TripCard extends StatelessWidget {
|
||||
/// The collapsed trip brief: progress, time left, cash — and everything else
|
||||
/// one tap away. See [TripBriefStrip] for what this used to be and why the
|
||||
/// four surfaces it replaced were the wrong price for the information.
|
||||
Widget buildSummary() => TripBriefStrip(
|
||||
/// [onBrand] hands the brief over for Home's trip tab panel: no white panel
|
||||
/// of its own, and its ink inverted for the brand red it lands on. See
|
||||
/// [TripSelector].
|
||||
Widget buildSummary({bool onBrand = false}) => TripBriefStrip(
|
||||
onBrand: onBrand,
|
||||
trip: trip,
|
||||
// What the four figures describe: the collections still owed, measured
|
||||
// from where the rider is standing — the same origin the page used to
|
||||
@@ -423,11 +428,42 @@ class TripCard extends StatelessWidget {
|
||||
///
|
||||
/// A group whose orders have all left Home — collected, and now on the work
|
||||
/// tab — emits nothing at all. Its heading would be a title over a gap.
|
||||
/// Every stop is drawn as the **place** it is worked at, with its orders in a
|
||||
/// list under it.
|
||||
///
|
||||
/// ── One shape for both lines ──
|
||||
///
|
||||
/// This was a flag, then a per-stop question, and it is now neither. The
|
||||
/// history is worth keeping because each step was right about something:
|
||||
///
|
||||
/// 1. `ServiceProfile.sourceIsKitchen` — a milk round grouped its orders
|
||||
/// under the counter they are loaded at; a logistics route grouped
|
||||
/// nothing. The argument for the second half was that a parcel rider
|
||||
/// "goes to one door for one consignment", so a header above a single
|
||||
/// order would print the same name twice.
|
||||
/// 2. Then deliveries were added to it: a booking the rider *hands over* was
|
||||
/// loaded at the depot, and six drops drawing six nodes repeated one base
|
||||
/// address six times.
|
||||
///
|
||||
/// The premise under step 1 was wrong, and the milk run is the proof. A place
|
||||
/// holding one order does not print the same name twice: the header names
|
||||
/// **where** — `VBK Complex, 11C-18, Ramasamy Rd` — and the row under it
|
||||
/// names **who** — `Jai Hari Shankar P R`. Those are two different facts, and
|
||||
/// on a collection the rider needs both: the address is what he rides to, the
|
||||
/// customer is who he asks for when he gets there. Flattening them threw the
|
||||
/// address away and titled the card with the person.
|
||||
///
|
||||
/// So every stop groups by its source now, and the shape of Home is one shape
|
||||
/// on both lines. Two collections off one address gather, which they always
|
||||
/// should have; a lone one is a place holding one order. The only stop still
|
||||
/// drawn flat is one with nothing to name a place by at all — no counter and
|
||||
/// no address — where there is genuinely no header to write. See the `flat`
|
||||
/// rule below, which is unchanged.
|
||||
|
||||
@visibleForTesting
|
||||
List<RouteGroup> routeGroups() {
|
||||
final order = <String>[];
|
||||
final buckets = <String, List<int>>{};
|
||||
final grouped = ServiceProfile.active.sourceIsKitchen;
|
||||
|
||||
// Every stop is bucketed, not only the ones Home still draws: a group's bag
|
||||
// numbers are counted against the whole group the rider was handed, so the
|
||||
@@ -449,9 +485,23 @@ class TripCard extends StatelessWidget {
|
||||
// is the same two-readers-one-question failure `sourceNameOf` documents,
|
||||
// repeated on the id, and it splits a kitchen's orders across two groups
|
||||
// on the screen while the collect still treats them as one.
|
||||
final key = grouped
|
||||
// ── One key for a milk round's counter, one for the whole depot ──
|
||||
//
|
||||
// A kitchen line has several counters in a day and the rider loads at
|
||||
// each in turn, so its stops key on the counter they came off.
|
||||
//
|
||||
// A logistics day has one building. It assigns the day's bookings —
|
||||
// collections, drops and both — hands over the route, and the rider
|
||||
// accepts the lot before he leaves. So the run belongs to that building,
|
||||
// and every stop keys to it however the individual booking is addressed.
|
||||
// See [RiderHub].
|
||||
//
|
||||
// `base`, not the internal word for it: `no_hub_in_rider_copy_test`
|
||||
// forbids that string in this layer and it is right to — a bucket key is
|
||||
// one rename away from being a label.
|
||||
final key = ServiceProfile.active.sourceIsKitchen
|
||||
? MilkRun.sourceKeyOf(stop, within: trip.stops)
|
||||
: 'stop:${_idOf(stop)}';
|
||||
: 'base';
|
||||
if (!buckets.containsKey(key)) {
|
||||
buckets[key] = <int>[];
|
||||
order.add(key);
|
||||
@@ -522,22 +572,35 @@ class TripCard extends StatelessWidget {
|
||||
// to name its place with — no counter name and no address. With an
|
||||
// address it is a place holding one order, headed by where it is, with
|
||||
// the order in the list under it.
|
||||
// Every bucket is a place now, so the only stop still drawn flat is one
|
||||
// with nothing to head it with — no counter name and no address. There
|
||||
// is genuinely no header to write for that one.
|
||||
final flat =
|
||||
all.length == 1 &&
|
||||
(!grouped || (stopSourceName(first).isEmpty && place.isEmpty));
|
||||
all.length == 1 && stopSourceName(first).isEmpty && place.isEmpty;
|
||||
|
||||
// A grouped place is named by its counter, or failing that by where it
|
||||
// is. A flat one *is* the stop, so it is named by the customer —
|
||||
// `MilkRun.navigationLabel` answers "pickup" for a parcel that has not
|
||||
// been collected yet, which is a description of the leg rather than a
|
||||
// name the rider can read off a gate.
|
||||
// ── The head card is named after the place, and the depot is a place ──
|
||||
//
|
||||
// On a logistics run that place is the rider's **hub**, not any one
|
||||
// booking's address. Reading it off the stops titled the card with a
|
||||
// customer's street — right for the drops, which are loaded at the
|
||||
// depot, and wrong for the collections, which are not.
|
||||
//
|
||||
// It falls back through what the payload names and then the address, so
|
||||
// a build with no locations table still draws the card it drew before
|
||||
// rather than an untitled one.
|
||||
final hub = ServiceProfile.active.sourceIsKitchen ? '' : RiderHub.name;
|
||||
final name = flat
|
||||
? OrderManifest.customerOf(first)
|
||||
: (grouped
|
||||
? (stopSourceName(first).trim().isNotEmpty
|
||||
? stopSourceName(first)
|
||||
: place)
|
||||
: MilkRun.navigationLabel(first));
|
||||
: (hub.isNotEmpty
|
||||
? hub
|
||||
: stopSourceName(first).trim().isNotEmpty
|
||||
? stopSourceName(first)
|
||||
: place);
|
||||
return RouteGroup(
|
||||
key: key,
|
||||
// A route with no source names still groups — into one unnamed
|
||||
@@ -559,7 +622,16 @@ class TripCard extends StatelessWidget {
|
||||
// components that are identical on every stop of every route and
|
||||
// would push the part that names the street off the end of the line.
|
||||
// Not repeated when it is already the heading — see [name] above.
|
||||
location: flat || name.trim() == place ? '' : place,
|
||||
//
|
||||
// ── And never a booking's street under the hub's name ──
|
||||
//
|
||||
// `place` is the first stop's address, which is the right subtitle
|
||||
// for a counter the stops were collected from and quite wrong under
|
||||
// a depot: it printed a customer's road under `H1`, which reads as
|
||||
// the hub's address and is not. The locations table gives the hub a
|
||||
// name and no street, so when the head is the hub the line is left
|
||||
// empty rather than filled with something that looks like an answer.
|
||||
location: flat || hub.isNotEmpty || name.trim() == place ? '' : place,
|
||||
stops: stops,
|
||||
allStops: all,
|
||||
firstIndex: indices.first,
|
||||
@@ -857,7 +929,7 @@ class TripCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -879,7 +951,7 @@ class TripCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.1,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -1008,7 +1080,7 @@ class TripCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.7,
|
||||
color: _hubColor,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -1101,7 +1173,7 @@ class TripCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: picked.isEmpty
|
||||
? ColorConstants.secondaryText
|
||||
@@ -1117,7 +1189,7 @@ class TripCard extends StatelessWidget {
|
||||
all ? 'CLEAR' : 'ALL',
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.6,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -1379,7 +1451,7 @@ class TripCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.acceptGreen,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -1429,7 +1501,7 @@ class TripCard extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -1477,7 +1549,7 @@ class _ViewRouteAction extends StatelessWidget {
|
||||
'MAP',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.6,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
|
||||
@@ -62,7 +62,7 @@ class TripEmptyState extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
// Generous top air. With the card gone there is no edge to separate this
|
||||
// from the pinned tab bar, so the space does that job — and an empty
|
||||
// from the tab bar above, so the space does that job — and an empty
|
||||
// screen is the one place in the app that can afford it.
|
||||
//
|
||||
// Vertical only: [MilerEmptyState] already insets its copy by 40 a side,
|
||||
|
||||
@@ -404,7 +404,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
// Fixed against the box: the circle does not grow with the system
|
||||
// font scale, so the digits inside it cannot either.
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
height: 1,
|
||||
color: ink,
|
||||
@@ -482,7 +482,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -494,7 +494,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
allDone ? 'All stops done' : '$done of $total done',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: allDone
|
||||
? ColorConstants.acceptGreen
|
||||
@@ -541,7 +541,7 @@ class _TripProgressRailState extends State<TripProgressRail> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: allDone
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.secondaryText,
|
||||
|
||||
@@ -1,83 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:tab_container/tab_container.dart';
|
||||
|
||||
import 'package:miler/views/Dashboard/home/trip.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE TRIP SELECTOR — 1 · 2 · 3, under the header on white
|
||||
/// THE TRIP SELECTOR — 1 · 2 · 3, and what the chosen slot is
|
||||
///
|
||||
/// A rider is assigned at most three trips a day, so three targets fit across a
|
||||
/// phone and every trip is one tap away.
|
||||
///
|
||||
/// ── It has no container ──
|
||||
/// ── It is a [TabContainer] now ──
|
||||
///
|
||||
/// 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.
|
||||
/// The control used to be a bare row of pills with a rule travelling under
|
||||
/// them, drawn by hand: a marker animation, a per-pill width measurement pass,
|
||||
/// and an overflow menu once a hub assigned more than three trips. All of that
|
||||
/// was mechanism — tab geometry, tab transitions, tab overflow — re-implemented
|
||||
/// for one screen.
|
||||
///
|
||||
/// It is a row of pills now, on the white page under the header:
|
||||
/// `tab_container` owns that mechanism now. It draws the tab strip as part of
|
||||
/// the panel's own shape, so the selected tab *is* the top edge of the body
|
||||
/// below it, and it carries the selection between tabs itself. Past three
|
||||
/// trips its tab strip scrolls, which is what the overflow menu was for.
|
||||
///
|
||||
/// ```
|
||||
/// ╭─────────╮ ╭─────────╮ ╭─────────╮
|
||||
/// │ Trip 1 │ │ Trip 2 │ │ Trip 3 │
|
||||
/// ╰─────────╯ ╰─────────╯ ╰─────────╯
|
||||
/// ━━━━━
|
||||
/// ╭────────╮╭────────╮ ╭────────╮
|
||||
/// │ Trip 1 ││ Trip 2 │ │ Trip 3 │
|
||||
/// │ ━━━ ││ │ │ │
|
||||
/// ├────────╯╰────────┴─┴────────┴──────────╮
|
||||
/// │ 10:00 AM – 1:00 PM · 12 to collect │
|
||||
/// ╰────────────────────────────────────────╯
|
||||
/// ```
|
||||
///
|
||||
/// 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.
|
||||
/// ── The rule stayed ──
|
||||
///
|
||||
/// ── Why it came off the brand ground ──
|
||||
/// The package's own idiom is that the selected tab reads as a shaped
|
||||
/// extension of the panel. On its own that is a *filled* tab, and a filled tab
|
||||
/// is the thing this control has twice been redesigned away from — it makes a
|
||||
/// filter look like a primary action and competes with the real button at the
|
||||
/// foot of the card below.
|
||||
///
|
||||
/// 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.
|
||||
/// So selection is still a short brand rule under the chosen numeral, drawn
|
||||
/// inside the tab itself. The panel's shape says which tab owns the body; the
|
||||
/// rule says which trip the rider is on. `test/card_density_test.dart` holds
|
||||
/// both — one mark at a time, and a rule rather than a fill.
|
||||
///
|
||||
/// ── And it says only which trip it is ──
|
||||
/// ── The body is the trip's own brief, and nothing else ──
|
||||
///
|
||||
/// 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.
|
||||
/// A [TabContainer] has a body, and an empty one would be a panel drawn around
|
||||
/// nothing. What goes in it is the brief the tabs choose between — the figures
|
||||
/// for the selected trip — handed in bare, because this panel is already the
|
||||
/// surface they stand on.
|
||||
///
|
||||
/// It carried a line of its own for a version: `1 delivery on this round`,
|
||||
/// under a heading that already read `1 stop left`. One panel, one count,
|
||||
/// stated twice — and on the device the two sat eight points apart. A panel
|
||||
/// under a set of tabs does not also need to narrate what the tabs selected.
|
||||
///
|
||||
/// The panel deliberately does **not** repeat `33 / 50` either. That ratio is
|
||||
/// the headline of the card below in type four times this size; it was on these
|
||||
/// tabs once and was taken off for exactly that reason. A filter says what it
|
||||
/// filters to.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class TripSelector extends StatefulWidget {
|
||||
final List<Trip> trips;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelect;
|
||||
|
||||
/// Optimistic accept/reject sets, for the per-trip count.
|
||||
/// Optimistic accept/reject sets, for the per-slot count in the brief.
|
||||
final Set<String> acceptedIds;
|
||||
final Set<String> rejectedIds;
|
||||
|
||||
/// Trips shown inline before the rest collapse into the overflow menu.
|
||||
/// The selected trip's whole page — brief, route and action bar.
|
||||
///
|
||||
/// 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.
|
||||
/// ── The panel encloses the work, it does not sit above it ──
|
||||
///
|
||||
/// The selector used to be a control in a sliver of its own, with the trip's
|
||||
/// content in the next sliver down: two unrelated boxes, one of which
|
||||
/// happened to govern the other. A [TabContainer] is a *container*, and the
|
||||
/// thing it is built to contain is the content its tabs choose — so the
|
||||
/// trip's page is handed in here and the selected tab is literally the top
|
||||
/// edge of it.
|
||||
///
|
||||
/// Null leaves the control standing on its own at [height], which is what the
|
||||
/// widget tests measure and what any caller that only wants the tabs gets.
|
||||
final Widget? body;
|
||||
|
||||
/// The number of tab slots ALWAYS rendered.
|
||||
///
|
||||
/// A rider's day is three trips, so 2 and 3 are shown before the hub has
|
||||
/// assigned them. A tab that appears and disappears through the morning makes
|
||||
/// the screen feel unstable; an empty third slot answers the question he
|
||||
/// actually has. Past three, the hub's own count wins and the strip scrolls.
|
||||
static const int maxInlineTabs = 3;
|
||||
|
||||
/// The travelling selection indicator.
|
||||
/// The selection rule.
|
||||
static const Key markerKey = ValueKey('trip_tab_marker');
|
||||
|
||||
/// The control's height — the pill, the air under it, and the indicator.
|
||||
/// The tab strip — [TabContainer.tabExtent].
|
||||
///
|
||||
/// 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.
|
||||
/// Comfortably over the 44pt tap floor without being a button: this is a
|
||||
/// filter, and a filter that looks like a primary action competes with the
|
||||
/// one at the foot of the card below it.
|
||||
static double get pillHeight => 42.sp;
|
||||
|
||||
/// The rule's thickness — the spec's 2–3dp.
|
||||
static const double indicator = 3;
|
||||
|
||||
/// The panel's own inner padding, measured to land its copy on the same
|
||||
/// vertical as the cards below it.
|
||||
///
|
||||
/// A [MilerPanel] insets its content by its gutter, its padding and the one
|
||||
/// point of border it keeps — which is why `TODAY'S RUN` begins at 29 and why
|
||||
/// `home_gutter_test` measures that number. This panel is a [TabContainer]
|
||||
/// rather than a [MilerPanel], and its caller supplies the gutter, so what is
|
||||
/// left for it to add is the other two parts. Built from the same tokens, not
|
||||
/// from a number that happens to match today.
|
||||
static double get contentInset => MilerSurface.panelPad + 1;
|
||||
|
||||
/// The control's own height: strip plus brief, with no [body] in it.
|
||||
///
|
||||
/// Fixed, because the tabs and the line under them must not move under the
|
||||
/// rider's thumb as trips arrive through the morning. With a [body] the panel
|
||||
/// is as tall as the page it encloses and this is just its head.
|
||||
///
|
||||
/// ── And capped, because it is charged continuously ──
|
||||
///
|
||||
/// This is header furniture: the rider sees it at every scroll position, so
|
||||
/// every point it takes is a point the route below never gets. The budget is
|
||||
/// 80 and `card_density_test.dart` holds it. Adding the body cost the strip
|
||||
/// two points rather than the budget four — the panel earns its height by
|
||||
/// saying what the slot is, not by being taller than the row it replaced.
|
||||
///
|
||||
/// ── Why there is no `.h` in this sum ──
|
||||
///
|
||||
@@ -87,18 +144,7 @@ class TripSelector extends StatefulWidget {
|
||||
/// 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;
|
||||
static double get height => pillHeight;
|
||||
|
||||
const TripSelector({
|
||||
super.key,
|
||||
@@ -107,6 +153,7 @@ class TripSelector extends StatefulWidget {
|
||||
required this.onSelect,
|
||||
this.acceptedIds = const {},
|
||||
this.rejectedIds = const {},
|
||||
this.body,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -114,110 +161,74 @@ class TripSelector extends StatefulWidget {
|
||||
}
|
||||
|
||||
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.
|
||||
with TickerProviderStateMixin {
|
||||
TabController? _tabs;
|
||||
|
||||
/// How many tab slots the strip draws.
|
||||
///
|
||||
/// 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.
|
||||
/// Three is the norm, not a guarantee — a hub can assign more, and when it
|
||||
/// does every one of them gets a tab rather than being folded away.
|
||||
int get _slotCount => widget.trips.length > TripSelector.maxInlineTabs
|
||||
? widget.trips.length
|
||||
: TripSelector.maxInlineTabs;
|
||||
|
||||
/// The selection, clamped into the strip.
|
||||
///
|
||||
/// 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;
|
||||
/// The page owns the selection and is allowed to hold an index this control
|
||||
/// cannot draw — a slot that existed before a refresh shortened the day. A
|
||||
/// [TabController] cannot, so it is clamped here rather than asserted.
|
||||
int get _safeIndex => widget.selectedIndex.clamp(0, _slotCount - 1);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_markerAnim = AnimationController(
|
||||
_buildController();
|
||||
}
|
||||
|
||||
void _buildController() {
|
||||
_tabs?.removeListener(_onTabChanged);
|
||||
_tabs?.dispose();
|
||||
_tabs = TabController(
|
||||
length: _slotCount,
|
||||
initialIndex: _safeIndex,
|
||||
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()),
|
||||
);
|
||||
)..addListener(_onTabChanged);
|
||||
}
|
||||
|
||||
double get _markerPos => _markerTween.evaluate(_markerCurve);
|
||||
|
||||
/// Trip indices rendered as inline segments, left to right.
|
||||
/// The controller moved — either the rider tapped, or [didUpdateWidget]
|
||||
/// drove it to follow the page.
|
||||
///
|
||||
/// 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();
|
||||
/// Reported on `indexIsChanging == false` as well as on settle, so a tap is
|
||||
/// answered as the animation starts rather than a frame after it lands: the
|
||||
/// page swaps the route below, and waiting for the tab to arrive first shows
|
||||
/// the outgoing trip sliding under the incoming label.
|
||||
void _onTabChanged() {
|
||||
final i = _tabs?.index;
|
||||
if (i == null || i == widget.selectedIndex) return;
|
||||
widget.onSelect(i);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TripSelector old) {
|
||||
super.didUpdateWidget(old);
|
||||
// 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);
|
||||
|
||||
// A day that grew or shrank needs a controller of the new length; there is
|
||||
// no way to re-length one in place.
|
||||
if (_tabs == null || _tabs!.length != _slotCount) {
|
||||
setState(_buildController);
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection is owned by the page, not by this control, so the travel
|
||||
// starts from here rather than from a tap handler. Guarded, or the
|
||||
// listener above and this line drive each other in a loop.
|
||||
if (_tabs!.index != _safeIndex) _tabs!.animateTo(_safeIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_markerAnim.dispose();
|
||||
_tabs?.removeListener(_onTabChanged);
|
||||
_tabs?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -226,347 +237,171 @@ class _TripSelectorState extends State<TripSelector>
|
||||
/// 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.
|
||||
/// first tab on a day with no morning stops, and the rider's trip 2 would not
|
||||
/// be the hub's.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inline = _inlineTabs();
|
||||
final hidden = _hiddenTabs(inline);
|
||||
final controller = _tabs!;
|
||||
final selected = _safeIndex;
|
||||
|
||||
// With nothing to enclose, the control is the tab strip and nothing else —
|
||||
// a fixed head, which is what `trip_tabs_pinned_test` measures.
|
||||
final body = widget.body;
|
||||
|
||||
return SizedBox(
|
||||
height: TripSelector.height,
|
||||
child: AnimatedBuilder(
|
||||
animation: _markerAnim,
|
||||
builder: (context, _) {
|
||||
final p = _markerPos;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Row(
|
||||
height: body == null ? TripSelector.height : null,
|
||||
child: TabContainer(
|
||||
controller: controller,
|
||||
tabEdge: TabEdge.top,
|
||||
tabExtent: TripSelector.pillHeight,
|
||||
// Wide enough to stay a target at any text scale. Past three trips the
|
||||
// strip scrolls at this width rather than shrinking every tab until
|
||||
// none of them can be hit.
|
||||
tabMinLength: 88.w,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
tabBorderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
// ── The app bar's own red ──
|
||||
//
|
||||
// The body and the selected tab are one shape in one fill, which is
|
||||
// what says which tab owns what is under it. That fill is the brand
|
||||
// bar's red — the same [ColorConstants.brandBarTop] the header above is
|
||||
// painted in, from one definition so the two cannot drift into being
|
||||
// *nearly* the same colour, which reads as a mistake rather than as a
|
||||
// family.
|
||||
//
|
||||
// A gradient is not available here: [TabContainer] paints the tab notch
|
||||
// and the body as one path in one colour. The bar's top stop is the
|
||||
// more saturated of its two and the one the eye reads as "the app's
|
||||
// red", so that is the one a solid takes.
|
||||
//
|
||||
// Everything inside the panel inverts to white with it — the brief
|
||||
// included, which is why it is handed over with
|
||||
// `buildSummary(onBrand: true)` rather than merely bare.
|
||||
//
|
||||
// The *unselected* tabs are not painted by this at all; they stand on
|
||||
// the page canvas beside the panel. That is what makes the control read
|
||||
// as tabs rather than as three words on a red band.
|
||||
color: ColorConstants.brandBarTop,
|
||||
// ── `childPadding` is not applied to a single `child` ──
|
||||
//
|
||||
// The package pads only the `children` branch: `_buildChild` reads
|
||||
// `widget.child ?? Padding(padding: childPadding, …)`, so handing it one
|
||||
// child skips the padding entirely and the brief sat hard against the
|
||||
// screen edge. It is passed anyway for the day this takes `children`,
|
||||
// and the inset that actually runs is [briefInset] below.
|
||||
childPadding: EdgeInsets.symmetric(horizontal: MilerSurface.panelPad),
|
||||
// The tabs are built here, fully styled, so the package must not
|
||||
// re-style them: its own selected/unselected interpolation would scale
|
||||
// the numeral and fight the rule underneath it.
|
||||
overrideTextProperties: true,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeInOutCubic,
|
||||
tabs: [
|
||||
for (var i = 0; i < _slotCount; i++)
|
||||
_TripTab(index: i, selected: i == selected),
|
||||
],
|
||||
// ── The dropdown is what the tabs are attached to ──
|
||||
//
|
||||
// The brief comes first and touches the tab strip, because the tabs
|
||||
// choose the trip and the brief is that trip's figures: the selected
|
||||
// tab is literally the top edge of the panel holding them. The slot
|
||||
// line closes the panel underneath, where it reads as a caption on the
|
||||
// figures rather than as something between the tabs and their content.
|
||||
child: body == null
|
||||
// Full width, zero height. `SizedBox.shrink()` is 0x0, and the
|
||||
// panel takes its width from its child — so an empty one collapsed
|
||||
// the whole control to nothing and the tabs stopped being tappable
|
||||
// while still being painted.
|
||||
? const SizedBox(width: double.infinity, height: 0)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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),
|
||||
),
|
||||
Padding(
|
||||
// Air under the tab strip. Without it the brief's heading
|
||||
// sits on the seam it shares with the selected tab, and the
|
||||
// two read as one squeezed block rather than as a tab and
|
||||
// the panel it opens.
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
TripSelector.contentInset,
|
||||
12,
|
||||
TripSelector.contentInset,
|
||||
0,
|
||||
),
|
||||
],
|
||||
if (hidden.isNotEmpty) ...[
|
||||
SizedBox(width: 10.w),
|
||||
_OverflowChip(
|
||||
width: 44.w,
|
||||
hidden: hidden,
|
||||
trips: widget.trips,
|
||||
onSelect: widget.onSelect,
|
||||
),
|
||||
],
|
||||
child: body,
|
||||
),
|
||||
SizedBox(height: TripSelector.contentInset),
|
||||
],
|
||||
),
|
||||
// ── 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.
|
||||
/// One tab: the trip's name, and the rule when it is the one being worked.
|
||||
///
|
||||
/// See [TripSelector] for why there is no count on it and no card around it.
|
||||
/// See [TripSelector] for why selection is a rule rather than the package's
|
||||
/// own filled tab.
|
||||
class _TripTab extends StatelessWidget {
|
||||
final int index;
|
||||
final bool selected;
|
||||
|
||||
/// 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,
|
||||
});
|
||||
const _TripTab({required this.index, required this.selected});
|
||||
|
||||
@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,
|
||||
),
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Scale-down rather than ellipsis: the numeral is the whole label and
|
||||
// `Trip 3` truncated to `Tri…` names nothing. This is also what keeps
|
||||
// the strip one height at 2.0× text — see [TripSelector.height].
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
Trip.tabLabel(index),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 14.sp,
|
||||
// ── Two grounds, so two inks ──
|
||||
//
|
||||
// The selected tab is part of the red panel; the others stand
|
||||
// on the page canvas beside it. There is no one colour for
|
||||
// both — brand red on the brand bar is invisible, slate on it
|
||||
// fails contrast — so the chosen numeral goes white and the
|
||||
// rest stay slate, quiet enough that the row reads as one
|
||||
// control with one choice in it rather than as three buttons.
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
SizedBox(height: 4.sp),
|
||||
// The rule. Only the selected tab draws it, so there is exactly one in
|
||||
// the tree at a time — which is what `card_density_test` asserts.
|
||||
SizedBox(
|
||||
height: TripSelector.indicator,
|
||||
width: 26.w,
|
||||
child: selected
|
||||
? Container(
|
||||
key: TripSelector.markerKey,
|
||||
decoration: BoxDecoration(
|
||||
// White, because the rule sits on the brand.
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user