Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
977
lib/views/Dashboard/home/stop_card.dart
Normal file
977
lib/views/Dashboard/home/stop_card.dart
Normal file
@@ -0,0 +1,977 @@
|
||||
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/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 bag this order travels in — `Bag 3`, or the label the kitchen
|
||||
/// printed. One order, one bag; the count is never a separate figure. See
|
||||
/// [BagManifest].
|
||||
final String bag;
|
||||
|
||||
/// 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.bag = '',
|
||||
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,
|
||||
});
|
||||
|
||||
bool get _live => state == StopState.active;
|
||||
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.
|
||||
final twoLeg = ServiceProfile.active.deliversToCustomer && 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.w800,
|
||||
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.w500,
|
||||
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,
|
||||
bagShownAbove: twoLeg && !grouped && bag.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
|
||||
? const LiveMark()
|
||||
: (state == StopState.pending &&
|
||||
!ServiceProfile.active.handsOffAtCollection)
|
||||
? null
|
||||
: StopStateChip(state: state, filled: true);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
kind.chipLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
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 bag 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.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bag.isNotEmpty) ...[SizedBox(width: 10.w), _BagChip(label: bag)],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 bagShownAbove}) {
|
||||
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.w600,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// What is in it, and which bag it is. The bag appears here whenever the
|
||||
// drop line above is not already carrying it — a card must never be
|
||||
// silent about which box this order is, and it must never say it twice.
|
||||
Builder(
|
||||
builder: (_) {
|
||||
final label = bag.isNotEmpty ? bag : stopBagLabel(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-bag rule restated
|
||||
// as an item count, which is exactly the second number this app
|
||||
// does not allow. Only the money survives.
|
||||
countsHidden: ServiceProfile.active.deliversToCustomer,
|
||||
);
|
||||
final showBag = !bagShownAbove && label.isNotEmpty;
|
||||
if (!showBag && meta.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 8.h),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!meta.isEmpty) Flexible(child: meta),
|
||||
if (showBag) ...[
|
||||
if (!meta.isEmpty) SizedBox(width: 10.w),
|
||||
_BagChip(label: label),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 bag, 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.w700,
|
||||
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 _BagChip extends StatelessWidget {
|
||||
final String label;
|
||||
const _BagChip({required this.label});
|
||||
|
||||
/// Backend labels are codes — `DG-1042`, `BAG-1` — and derived ones already
|
||||
/// read `Bag 3`. The word belongs on both, and the test for "already has it"
|
||||
/// is the *word*, not the prefix: `BAG-1` starts with those three letters and
|
||||
/// is still a code, so it becomes `Bag BAG-1` exactly as it always read.
|
||||
String get _text =>
|
||||
label.toLowerCase().startsWith('bag ') ? label : 'Bag $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.w700,
|
||||
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 {
|
||||
const LiveMark({super.key});
|
||||
|
||||
@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(
|
||||
'Active',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
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,
|
||||
),
|
||||
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.w800,
|
||||
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.w700,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user