Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
1008 lines
36 KiB
Dart
1008 lines
36 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/data/task_profile.dart';
|
|
import 'package:miler/views/Dashboard/home/trip.dart';
|
|
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
|
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
|
import 'package:miler/views/helpers/constants/design_constants.dart';
|
|
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
|
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// STOP CARD — one order, drawn as the journey it is.
|
|
///
|
|
/// ```
|
|
/// ✓ ACCEPTED ~5 min · by 12:30 PM ( )
|
|
///
|
|
/// ● 🍴 PICKUP
|
|
/// │ Vidhya Kitchen
|
|
/// │ 📍 Ritham Tours & Travels 20, Gandhi nagar, Peelamedu…
|
|
/// │ Quantity 1
|
|
/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
|
|
/// ● 📦 DROP
|
|
/// Joe
|
|
/// 📍 12, SNS Colony, Peelamedu, Coimbatore
|
|
/// 🏢 Daily Grubs
|
|
/// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
|
|
/// 🧾 Stop 1 · #916-2024116374 [📞] [ⓘ]
|
|
///
|
|
/// ┌───────────────────────────────────────────────────────┐
|
|
/// │ ➤ Navigate to pickup │
|
|
/// └───────────────────────────────────────────────────────┘
|
|
/// ```
|
|
///
|
|
/// ── The three rules this card is built on ──
|
|
///
|
|
/// 1. **No boxes.** One pane of glass ([GlassCard]) with no border, and
|
|
/// nothing bordered inside it. Everything that used to be a box — the type
|
|
/// chip, the state panel, the reserved control slots, the tinted meta
|
|
/// tags — is type and colour on plain rows now.
|
|
///
|
|
/// 2. **The rider reads this at arm's length, in sun, on a bike.** So the
|
|
/// things he actually reads are large: the place name at 19sp, the address
|
|
/// at 14.5, the time he is working to at 13.5. Nothing on this card is
|
|
/// under 11.5.
|
|
///
|
|
/// 3. **One state, one next action.** The card always says where the order is
|
|
/// (the chip, top left) and offers exactly one thing to press for it
|
|
/// (bottom). An accepted order's one action is *get me there* — see
|
|
/// [_primaryAction].
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
class StopCard extends StatelessWidget {
|
|
final Map<String, dynamic> stop;
|
|
|
|
/// 1-based position in the route, printed in the footer beside the order id.
|
|
final int index;
|
|
|
|
final StopState state;
|
|
|
|
/// The pickup-side address, already shortened by the trip if the design's
|
|
/// tail-trimming is on.
|
|
final String address;
|
|
|
|
/// Time at the door, shown in the card's header.
|
|
final Duration service;
|
|
|
|
/// Which label this order travels in — `Bag 3`, or the label the kitchen
|
|
/// printed. One order, one label; the count is never a separate figure. See
|
|
/// [OrderManifest].
|
|
final String label;
|
|
|
|
/// True when this card sits under a heading that already names its pickup
|
|
/// location.
|
|
///
|
|
/// It changes what the card leads with. Under a kitchen heading, five cards
|
|
/// each headlined "Vidhya Kitchen" is the same name six times on one screen —
|
|
/// the heading, then once per card — and the one thing that actually tells
|
|
/// the five apart, the customer, was demoted to a line underneath. So a
|
|
/// grouped card leads with **who it is for** and shows the address it is
|
|
/// going to, which is also how the kitchen's own manifest reads.
|
|
final bool groupedUnderSource;
|
|
|
|
final bool selected;
|
|
|
|
/// True while this stop's acceptance is on the wire — the tick goes dead so
|
|
/// it cannot disagree with the request in flight.
|
|
final bool busy;
|
|
|
|
/// Opens the detail sheet. The whole card carries it, and so does the ⓘ tile.
|
|
final VoidCallback? onTap;
|
|
|
|
/// Null hides the tick entirely — see the note in [TripCard] on which states
|
|
/// are still choosable.
|
|
final VoidCallback? onToggleSelect;
|
|
|
|
final VoidCallback? onCall;
|
|
|
|
/// Opens turn-by-turn navigation to whichever leg this stop is on. This is
|
|
/// the accepted card's primary action, not a tile in a corner: an accepted
|
|
/// order's whole content is "go to this kitchen".
|
|
final VoidCallback? onNavigate;
|
|
|
|
/// Returns a rejected stop to undecided.
|
|
final VoidCallback? onUnreject;
|
|
|
|
/// Takes the rider back into the stop he is already working.
|
|
final VoidCallback? onContinue;
|
|
|
|
/// The next rung for a stop already under way, behind the same slide sheet
|
|
/// the selection bar uses. Only ever offered on an *arrived* stop — the rung
|
|
/// before that is reached by navigating there and ticking the counter's
|
|
/// orders. See [_primaryAction].
|
|
final VoidCallback? onAdvance;
|
|
final String? advanceLabel;
|
|
|
|
const StopCard({
|
|
super.key,
|
|
required this.stop,
|
|
required this.index,
|
|
required this.state,
|
|
required this.address,
|
|
required this.service,
|
|
this.label = '',
|
|
this.groupedUnderSource = false,
|
|
this.selected = false,
|
|
this.busy = false,
|
|
this.onTap,
|
|
this.onToggleSelect,
|
|
this.onCall,
|
|
this.onNavigate,
|
|
this.onUnreject,
|
|
this.onContinue,
|
|
this.onAdvance,
|
|
this.advanceLabel,
|
|
});
|
|
|
|
/// Both rungs a rider can be standing on. They render the same mark; what
|
|
/// differs is the word — see [LiveMark.label].
|
|
bool get _live => state == StopState.active || state == StopState.arrived;
|
|
bool get _rejected => state == StopState.rejected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final kind = stopKindOf(stop);
|
|
|
|
final source = stopSourceName(stop);
|
|
final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop')
|
|
.toString();
|
|
final drop = (stop['dropaddress'] ?? stop['DropAddress'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
|
|
// ── The card leads with where he is going ──
|
|
//
|
|
// Not with both ends of the journey. A meal order has two, and drawing both
|
|
// at equal weight — two avatars, two labels, two addresses joined by a
|
|
// thread — made every card a diagram of a journey rather than an answer to
|
|
// "where am I going and what do I do there". The destination takes the
|
|
// headline; the other end drops to one supporting line underneath.
|
|
//
|
|
// Before pickup the destination is the counter, because that is the address
|
|
// he is riding to. After it, the drop leads — and that card lives on the
|
|
// work tab, where [PickupCard] does exactly the same thing in the other
|
|
// direction.
|
|
// Per row: a CX pickup on a meal tenant is not a two-leg meal stop.
|
|
final twoLeg = WorkPolicy.deliversToCustomer(stop) && drop.isNotEmpty;
|
|
final grouped = groupedUnderSource && source.isNotEmpty;
|
|
final headline = (twoLeg && !grouped && source.isNotEmpty)
|
|
? source
|
|
: customer;
|
|
final headlineAddress = grouped && drop.isNotEmpty ? drop : address;
|
|
|
|
final action = _primaryAction();
|
|
|
|
return GlassCard(
|
|
margin: EdgeInsets.only(bottom: 12.h),
|
|
onTap: onTap,
|
|
opacity: _rejected ? 0.6 : 1,
|
|
tint: _live ? ColorConstants.glassCardLive : null,
|
|
child: Padding(
|
|
// One inset for the whole card, and the button spans it. The card used
|
|
// to run four different paddings — a header inset, a leg inset, a
|
|
// footer inset and a CTA inset — which is what made the left edge of
|
|
// the content wander down the card.
|
|
padding: EdgeInsets.fromLTRB(18.w, 16.h, 18.w, 16.h),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_statusRow(kind),
|
|
SizedBox(height: 10.h),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ── The headline ──
|
|
//
|
|
// The loudest thing on the card, by a clear step. It is
|
|
// what the rider shouts at a gate and what he checks a
|
|
// shop sign against, and at 19sp beside a 16sp address it
|
|
// was not winning by enough to be found without reading.
|
|
Text(
|
|
headline,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 21.sp,
|
|
fontWeight: FontWeight.w900,
|
|
height: 1.12,
|
|
letterSpacing: -0.6,
|
|
color: ColorConstants.slateText,
|
|
decoration: _rejected
|
|
? TextDecoration.lineThrough
|
|
: null,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
if (headlineAddress.isNotEmpty) ...[
|
|
SizedBox(height: 5.h),
|
|
Text(
|
|
headlineAddress,
|
|
maxLines: kFullAddressTrial ? 3 : 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
height: 1.4,
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
// Ringing ahead is the one thing a rider does from a list that
|
|
// is not "go there", so it keeps a place on the card — a single
|
|
// quiet glyph, not one of a row of tiles.
|
|
if (onCall != null) ...[
|
|
SizedBox(width: 10.w),
|
|
_IconAction(
|
|
icon: LucideIcons.phone,
|
|
semanticLabel: 'Call this stop',
|
|
onTap: onCall!,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (twoLeg && !grouped) ...[
|
|
SizedBox(height: 12.h),
|
|
_dropLine(customer),
|
|
],
|
|
SizedBox(height: 12.h),
|
|
_factsRow(
|
|
kind,
|
|
labelShownAbove: twoLeg && !grouped && label.isNotEmpty,
|
|
),
|
|
if (action != null) ...[SizedBox(height: 16.h), action],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A quantity of at least one: a stop that carries nothing is not a stop, and
|
|
/// a payload that forgot to say so should not print "0".
|
|
int _qty(int v) => v > 0 ? v : 1;
|
|
|
|
/// The type, the state and the tick, on one line above the name.
|
|
///
|
|
/// All three are *marks*: small, quiet, and never competing with the address
|
|
/// underneath. The state chip is the only one that carries colour, because it
|
|
/// is the only one that changes.
|
|
Widget _statusRow(StopKind kind) {
|
|
final chip = _live
|
|
? LiveMark(label: state == StopState.arrived ? 'Arrived' : 'Active')
|
|
: (state == StopState.pending &&
|
|
!WorkPolicy.staysOnHomeUntilCollected(stop))
|
|
? null
|
|
: StopStateChip(state: state, filled: true);
|
|
|
|
return Row(
|
|
children: [
|
|
Text(
|
|
kind.chipLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
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.
|
|
color: kind.chipColor,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(width: 10.w),
|
|
if (chip != null) Flexible(child: chip),
|
|
const Spacer(),
|
|
if (onToggleSelect != null)
|
|
SelectBox(
|
|
selected: selected,
|
|
accent: ColorConstants.primary,
|
|
label: 'Select stop $index',
|
|
circular: true,
|
|
// Dead while this stop's acceptance is on the wire: un-ticking it
|
|
// there would leave the tick and the request disagreeing.
|
|
onTap: busy ? null : onToggleSelect,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// `→ Joe · Bag 1` — the far end, in one line.
|
|
///
|
|
/// Everything the second leg used to spend a whole block on: who it is for,
|
|
/// and which label it is. The address is one tap away in the detail sheet,
|
|
/// where a rider going to a *kitchen* has no use for it yet.
|
|
Widget _dropLine(String customer) {
|
|
return Row(
|
|
children: [
|
|
Icon(
|
|
LucideIcons.arrowRight,
|
|
size: 16.sp,
|
|
color: ColorConstants.deliveryChip,
|
|
),
|
|
SizedBox(width: 7.w),
|
|
Flexible(
|
|
child: Text(
|
|
customer,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15.5.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.2,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
if (label.isNotEmpty) ...[
|
|
SizedBox(width: 10.w),
|
|
_LabelChip(label: label),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The figures, on one line: time at the door, when it is due, what is in it.
|
|
///
|
|
/// This is the line a rider re-reads at a red light, so nothing on it is
|
|
/// under 14sp and none of it is in a box.
|
|
Widget _factsRow(StopKind kind, {required bool labelShownAbove}) {
|
|
final due = DateTime.tryParse(
|
|
(stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(),
|
|
);
|
|
final cash = stopCollectionAmount(stop);
|
|
|
|
final parts = <String>[
|
|
if (service > Duration.zero) '~${formatTripDuration(service)}',
|
|
if (due != null) 'by ${RouteMetricsHelper.formatClock(due)}',
|
|
// Position in the route. The trip's own index, which does not renumber
|
|
// itself as stops leave the screen.
|
|
'Stop $index',
|
|
];
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
LucideIcons.clock,
|
|
size: 16.sp,
|
|
color: ColorConstants.secondaryText,
|
|
),
|
|
SizedBox(width: 7.w),
|
|
Expanded(
|
|
child: Text(
|
|
parts.join(' · '),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.2,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// What is in it, and the counter's own label where there is one. It
|
|
// appears here whenever the drop line above is not already carrying it:
|
|
// a card must never say the same thing twice.
|
|
Builder(
|
|
builder: (_) {
|
|
final printed = label.isNotEmpty ? label : stopPrintedLabel(stop);
|
|
final meta = _MetaLine(
|
|
deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0,
|
|
collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0,
|
|
cash: cash,
|
|
muted: _rejected || state == StopState.done,
|
|
// On a meal run the parcel counts are the one-label rule restated
|
|
// as an item count, which is exactly the second number this app
|
|
// does not allow. Only the money survives.
|
|
countsHidden: WorkPolicy.deliversToCustomer(stop),
|
|
);
|
|
final showLabel = !labelShownAbove && printed.isNotEmpty;
|
|
if (!showLabel && meta.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(top: 8.h),
|
|
child: Row(
|
|
children: [
|
|
if (!meta.isEmpty) Flexible(child: meta),
|
|
if (showLabel) ...[
|
|
if (!meta.isEmpty) SizedBox(width: 10.w),
|
|
_LabelChip(label: printed),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The one thing to press on this card, sized like it matters.
|
|
///
|
|
/// ── Why an accepted order's action is "navigate" ──
|
|
///
|
|
/// Accepting is a promise; the next physical act is riding to the counter.
|
|
/// The rung after that — *I have arrived* — is a claim about a place, and it
|
|
/// is made from the selection bar with every order from that counter ticked,
|
|
/// because he arrives at a kitchen, not at an order. So the card offers the
|
|
/// journey and the bar offers the claim, and neither duplicates the other.
|
|
///
|
|
/// A stop that is already *arrived* is the one exception: the single-order
|
|
/// case ("one label, one counter") would otherwise make him tick a card he is
|
|
/// looking at to reach a control that talks about "the selection". That
|
|
/// button opens the same slide sheet the bar does, over the same batch.
|
|
Widget? _primaryAction() {
|
|
Widget wrap(Widget child) => Padding(
|
|
padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 16.h),
|
|
child: SizedBox(width: double.infinity, child: child),
|
|
);
|
|
|
|
if (_rejected) {
|
|
return onUnreject == null
|
|
? null
|
|
: wrap(
|
|
MilerButton(
|
|
label: 'Undo reject',
|
|
icon: LucideIcons.undo2,
|
|
variant: MilerButtonVariant.outlined,
|
|
color: ColorConstants.slateText,
|
|
height: ButtonSizes.secondary,
|
|
loading: busy,
|
|
onPressed: busy ? null : onUnreject,
|
|
),
|
|
);
|
|
}
|
|
|
|
// The stop under way, on a parcel route: back into the verification flow.
|
|
if (_live && onContinue != null) {
|
|
return wrap(
|
|
MilerButton(
|
|
label: stopKindOf(stop).isDelivery
|
|
? 'Continue delivery'
|
|
: 'Continue pickup',
|
|
icon: LucideIcons.arrowRight,
|
|
color: ColorConstants.primary,
|
|
height: ButtonSizes.primary,
|
|
onPressed: onContinue,
|
|
),
|
|
);
|
|
}
|
|
|
|
// The stop under way, on a service route: the next rung, over the batch at
|
|
// this counter.
|
|
if (_live && onAdvance != null && advanceLabel != null) {
|
|
return wrap(
|
|
MilerButton(
|
|
label: advanceLabel!,
|
|
icon: LucideIcons.shoppingBag,
|
|
color: ColorConstants.acceptGreen,
|
|
height: ButtonSizes.primary,
|
|
loading: busy,
|
|
onPressed: busy ? null : onAdvance,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Under way, with nothing on this screen able to move it on: say where it
|
|
// *is* worked. A sentence, not a button — a control that cannot be pressed
|
|
// is not a status, and this is the one case where the card has nothing to
|
|
// offer but wayfinding.
|
|
if (_live) {
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 16.h),
|
|
child: Row(
|
|
children: [
|
|
Icon(LucideIcons.bike, size: 17.sp, color: ColorConstants.primary),
|
|
SizedBox(width: 8.w),
|
|
Flexible(
|
|
child: Text(
|
|
'In progress — finish it in '
|
|
'${ServiceProfile.active.workTabLabel}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
fontWeight: FontWeight.w800,
|
|
color: ColorConstants.primary,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Taken on, and not yet there.
|
|
if (state == StopState.accepted && onNavigate != null) {
|
|
return wrap(
|
|
MilerButton(
|
|
label: 'Navigate to pickup',
|
|
icon: LucideIcons.navigation,
|
|
// Maroon, not green: across this app green *advances the job* and
|
|
// brand red is navigation and identity. Setting off changes nothing
|
|
// at the hub, so it must not wear the colour that does.
|
|
color: ColorConstants.primary,
|
|
height: ButtonSizes.primary,
|
|
onPressed: onNavigate,
|
|
),
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// A quiet round glyph button — the card's only icon control.
|
|
///
|
|
/// One, not a row of three. The footer used to carry call, navigate and
|
|
/// details as equal tiles; navigating is the card's primary button now and the
|
|
/// details are the card's own tap, which leaves exactly one thing worth a
|
|
/// glyph.
|
|
class _IconAction extends StatelessWidget {
|
|
final IconData icon;
|
|
final String semanticLabel;
|
|
final VoidCallback onTap;
|
|
|
|
const _IconAction({
|
|
required this.icon,
|
|
required this.semanticLabel,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
label: semanticLabel,
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: SizedBox(
|
|
width: 48.w,
|
|
height: 48.w,
|
|
child: Center(
|
|
child: Container(
|
|
width: 42.w,
|
|
height: 42.w,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.primary.withValues(alpha: 0.08),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(icon, size: 20.sp, color: ColorConstants.primary),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `Bag 3` — the one code the rider matches against a box in his hand.
|
|
///
|
|
/// A neutral chip, not a coloured one: it is an identifier, not a state, and
|
|
/// the card has exactly one colour to spend. It is a chip rather than plain
|
|
/// text because it is read *against an object* — a bordered word is findable on
|
|
/// a card at arm's length in a way a run of grey text is not.
|
|
class _LabelChip extends StatelessWidget {
|
|
final String label;
|
|
const _LabelChip({required this.label});
|
|
|
|
/// ── The word is gone; the code is not ──
|
|
///
|
|
/// This prefixed everything with `Bag ` — so a counter's own code rendered
|
|
/// as `Bag DG-1042`, and a number the app had derived rendered as `Bag 3`
|
|
/// and read like something stencilled on an object nobody had stencilled.
|
|
///
|
|
/// What a counter prints is printed on it already. It is shown exactly as it
|
|
/// was sent, and nothing is shown at all where nothing was sent.
|
|
String get _text => label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h),
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.neutralLight,
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
|
),
|
|
child: Text(
|
|
_text,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13.5.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.1,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Tick box for selecting a stop.
|
|
///
|
|
/// Square on the select-all strip, circular on a card — a card's tick sits
|
|
/// beside a round avatar and a round dot, and a square there was the only
|
|
/// corner in that column.
|
|
class SelectBox extends StatelessWidget {
|
|
final bool selected;
|
|
final bool partial;
|
|
final Color accent;
|
|
final String label;
|
|
final bool circular;
|
|
|
|
/// Null while the stop's acceptance is in flight.
|
|
final VoidCallback? onTap;
|
|
|
|
const SelectBox({
|
|
super.key,
|
|
required this.selected,
|
|
required this.accent,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.partial = false,
|
|
this.circular = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final on = selected || partial;
|
|
return Semantics(
|
|
checked: selected,
|
|
label: label,
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
// 48dp hit area around a 28px box: the visible control stays compact
|
|
// but the target clears the accessibility floor comfortably. This is
|
|
// the control the rider hits most on this screen, one-handed, in
|
|
// motion — it is worth the four extra points over the 44 floor.
|
|
child: Container(
|
|
width: 48.w,
|
|
height: 48.w,
|
|
alignment: Alignment.center,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 160),
|
|
width: 28.w,
|
|
height: 28.w,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: on ? accent : ColorConstants.pureSurface,
|
|
shape: circular ? BoxShape.circle : BoxShape.rectangle,
|
|
borderRadius: circular
|
|
? null
|
|
: BorderRadius.circular(DesignConstants.radiusLg),
|
|
border: Border.all(
|
|
color: on ? accent : ColorConstants.borderStrong,
|
|
width: 2,
|
|
),
|
|
),
|
|
child: on
|
|
? Icon(
|
|
partial ? LucideIcons.minus : LucideIcons.check,
|
|
size: 18.sp,
|
|
color: Colors.white,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `● Active` — the stop the rider is on, with a dot that breathes.
|
|
///
|
|
/// The word was `LIVE`, which is broadcast vocabulary: it says a stream is
|
|
/// on air, not that a rider is standing at a counter. **Active** is the word
|
|
/// the rest of the app already uses for this rung — the trip tab's status
|
|
/// line, the queue's own heading, `StopStatus.active` itself — so the one
|
|
/// state the rider must not miss now has one name everywhere he meets it.
|
|
///
|
|
/// Movement is what makes "this one is running right now" read without being
|
|
/// read; a static dot beside the word is a claim the interface does not back
|
|
/// up.
|
|
///
|
|
/// The controller is created on first *use* rather than on the declaration, so
|
|
/// a `dispose()` on a card that never painted cannot construct a ticker during
|
|
/// teardown — the same trap the banner hit, documented in `homepage_banner.dart`.
|
|
class LiveMark extends StatefulWidget {
|
|
/// The word beside the pulse.
|
|
///
|
|
/// It was the literal `'Active'`, always. Since `stopStateOf` folded arrival
|
|
/// into [StopState.active], sliding *confirm arrival* moved the rung, fired
|
|
/// `/reached` and wrote the local record — and then printed **Active** on the
|
|
/// row. From the rider's side that is indistinguishable from the arrival not
|
|
/// registering at all, which is exactly how it was reported.
|
|
///
|
|
/// The two states are separate now; this is how the mark says which one it
|
|
/// is. `Active` stays the default because the delivery half of the day is
|
|
/// where most live stops are.
|
|
final String label;
|
|
|
|
const LiveMark({super.key, this.label = 'Active'});
|
|
|
|
@override
|
|
State<LiveMark> createState() => _LiveMarkState();
|
|
}
|
|
|
|
class _LiveMarkState extends State<LiveMark>
|
|
with SingleTickerProviderStateMixin {
|
|
AnimationController? _controller;
|
|
|
|
AnimationController get _pulse => _controller ??= AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1100),
|
|
)..repeat(reverse: true);
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final accent = ColorConstants.primary;
|
|
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h),
|
|
decoration: BoxDecoration(
|
|
color: accent.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
AnimatedBuilder(
|
|
animation: _pulse,
|
|
builder: (context, _) {
|
|
// A halo that grows and fades around a dot that stays put:
|
|
// scaling the dot itself would shift the word beside it every
|
|
// frame.
|
|
return SizedBox(
|
|
width: 8.w,
|
|
height: 8.w,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Transform.scale(
|
|
scale: 1.0 + 1.1 * _pulse.value,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: accent.withValues(
|
|
alpha: 0.55 * (1 - _pulse.value),
|
|
),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: SizedBox(width: 8.w, height: 8.w),
|
|
),
|
|
),
|
|
Container(
|
|
width: 7.w,
|
|
height: 7.w,
|
|
decoration: BoxDecoration(
|
|
color: accent,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
SizedBox(width: 6.w),
|
|
Flexible(
|
|
child: Text(
|
|
widget.label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11.5.sp,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: 0.2,
|
|
color: accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Where this order stands, named in one word.
|
|
///
|
|
/// [filled] gives it the tinted pill it wears at the top of a card, where it is
|
|
/// the first thing read; inline uses — anywhere it is one fact among others —
|
|
/// keep the bare icon-and-word, because a pill in a paragraph is a box.
|
|
class StopStateChip extends StatelessWidget {
|
|
final StopState state;
|
|
final bool filled;
|
|
|
|
const StopStateChip({super.key, required this.state, this.filled = false});
|
|
|
|
({Color color, IconData icon}) get _look => switch (state) {
|
|
StopState.accepted => (
|
|
color: ColorConstants.acceptGreen,
|
|
icon: LucideIcons.circleCheck,
|
|
),
|
|
// In his hands. The service accent rather than green: green is the app's
|
|
// "done" colour everywhere else, and a collected order is the opposite of
|
|
// done — it is the point at which he owes somebody a delivery.
|
|
StopState.collected => (
|
|
color: ColorConstants.serviceAccent,
|
|
icon: LucideIcons.shoppingBag,
|
|
),
|
|
// At the door, not on the road: the pin, not the bike. The two used to
|
|
// share this row and the chip printed `Active` for both — see
|
|
// [StopState.arrived].
|
|
StopState.arrived => (
|
|
color: ColorConstants.primary,
|
|
icon: LucideIcons.mapPin,
|
|
),
|
|
StopState.active => (color: ColorConstants.primary, icon: LucideIcons.bike),
|
|
StopState.done => (
|
|
color: ColorConstants.acceptGreen,
|
|
icon: LucideIcons.badgeCheck,
|
|
),
|
|
StopState.skipped => (
|
|
color: ColorConstants.warning,
|
|
icon: LucideIcons.rotateCcw,
|
|
),
|
|
StopState.rejected => (
|
|
color: ColorConstants.secondaryText,
|
|
icon: LucideIcons.ban,
|
|
),
|
|
StopState.pending => (
|
|
color: ColorConstants.secondaryText,
|
|
icon: LucideIcons.clock,
|
|
),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final look = _look;
|
|
final row = Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(look.icon, size: filled ? 15.sp : 13.sp, color: look.color),
|
|
SizedBox(width: 6.w),
|
|
Flexible(
|
|
child: Text(
|
|
state.label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: filled ? 12.5.sp : 11.sp,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: 0.3,
|
|
color: look.color,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
if (!filled) return row;
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h),
|
|
decoration: BoxDecoration(
|
|
color: look.color.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
),
|
|
child: row,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// ── The work at this stop, on one line ──
|
|
///
|
|
/// Plain text rather than tinted chips. The rule for a figure that has to
|
|
/// survive a glance is weight and contrast, not a container; three containers
|
|
/// on one row was the opposite of that, and on a combined stop they wrapped
|
|
/// onto a second line and made the card taller than its neighbours.
|
|
///
|
|
/// Only the cash is coloured. Parcel counts are labels; the money is a state
|
|
/// the rider is accountable for, and green means money throughout the app.
|
|
class _MetaLine extends StatelessWidget {
|
|
final int deliver;
|
|
final int collect;
|
|
final double cash;
|
|
final bool muted;
|
|
|
|
/// Suppresses the parcel counts, leaving only money. See the call site.
|
|
final bool countsHidden;
|
|
|
|
const _MetaLine({
|
|
required this.deliver,
|
|
required this.collect,
|
|
required this.cash,
|
|
this.muted = false,
|
|
this.countsHidden = false,
|
|
});
|
|
|
|
/// True when there is nothing to say, so the caller can skip its own spacing
|
|
/// rather than laying out a gap around a zero-height widget.
|
|
bool get isEmpty =>
|
|
cash <= 0 && (countsHidden || (deliver <= 0 && collect <= 0));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
TextStyle style(Color c) => TextStyle(
|
|
fontSize: 14.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.2,
|
|
color: c,
|
|
fontFamily: FontConstants.fontFamily,
|
|
);
|
|
|
|
final dot = TextSpan(
|
|
text: ' · ',
|
|
style: style(ColorConstants.borderStrong),
|
|
);
|
|
|
|
final body = muted
|
|
? ColorConstants.secondaryText
|
|
: ColorConstants.slateText;
|
|
|
|
final spans = <InlineSpan>[];
|
|
if (deliver > 0 && !countsHidden) {
|
|
spans.add(TextSpan(text: 'Deliver $deliver', style: style(body)));
|
|
}
|
|
if (collect > 0 && !countsHidden) {
|
|
if (spans.isNotEmpty) spans.add(dot);
|
|
spans.add(TextSpan(text: 'Collect $collect', style: style(body)));
|
|
}
|
|
if (cash > 0) {
|
|
if (spans.isNotEmpty) spans.add(dot);
|
|
spans.add(
|
|
TextSpan(
|
|
text: '₹${_trim(cash)} cash',
|
|
style: style(ColorConstants.moneyGreen),
|
|
),
|
|
);
|
|
}
|
|
if (spans.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Text.rich(
|
|
TextSpan(children: spans),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Rupee amounts without trailing noise: `1030`, `40`, `12.5`.
|
|
String _trim(double v) {
|
|
if (v >= 100) return v.round().toString();
|
|
final rounded = (v * 10).round() / 10;
|
|
return rounded == rounded.roundToDouble()
|
|
? rounded.round().toString()
|
|
: rounded.toStringAsFixed(1);
|
|
}
|