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>
1020 lines
38 KiB
Dart
1020 lines
38 KiB
Dart
part of 'pickups.dart';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// PICKUP DONE SCREEN — PREMIUM REDESIGN (Scratch Card + Standard)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class PickupsDone extends StatefulWidget {
|
|
final bool isCancelled;
|
|
final bool isDelivery;
|
|
final int bonusPoints;
|
|
final List<Map<String, dynamic>>? remainingOrders;
|
|
final String? paymentMethod;
|
|
final _MyPickupsState? parentState;
|
|
|
|
const PickupsDone({
|
|
super.key,
|
|
this.isCancelled = false,
|
|
this.isDelivery = false,
|
|
this.bonusPoints = 0,
|
|
this.remainingOrders,
|
|
this.paymentMethod,
|
|
this.parentState,
|
|
});
|
|
|
|
@override
|
|
State<PickupsDone> createState() => _PickupsDoneState();
|
|
}
|
|
|
|
class _PickupsDoneState extends State<PickupsDone>
|
|
with TickerProviderStateMixin {
|
|
late ConfettiController _confettiController;
|
|
final GlobalKey<ScratcherState> _scratcherKey = GlobalKey<ScratcherState>();
|
|
double _opacity = 0.0;
|
|
bool _isScratched = false;
|
|
|
|
late AnimationController _entranceController;
|
|
late Animation<double> _circleScaleAnim;
|
|
late Animation<double> _fadeAnim;
|
|
late Animation<Offset> _slideAnim;
|
|
|
|
/// The next few stops, capped for display.
|
|
List<Map<String, dynamic>> _nextOrders = [];
|
|
|
|
/// How many stops are actually left, before the display cap. The screen
|
|
/// tells the rider where he is in the day, and "3 left" when 11 remain is
|
|
/// worse than saying nothing.
|
|
int _remainingCount = 0;
|
|
bool _loadingNextOrders = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_confettiController = ConfettiController(
|
|
duration: const Duration(seconds: 4),
|
|
);
|
|
|
|
// 800 → 300. This screen sits between two stops and the rider crosses it
|
|
// 40 times a shift; an eight-hundred-millisecond entrance is half a second
|
|
// of standing still, 40 times. The celebration branch keeps its own longer
|
|
// beat — see [DesignConstants.motionCelebrate] — because that fires once
|
|
// per completed route, not once per stop.
|
|
_entranceController = AnimationController(
|
|
duration: DesignConstants.motionPage,
|
|
vsync: this,
|
|
);
|
|
|
|
_circleScaleAnim = CurvedAnimation(
|
|
parent: _entranceController,
|
|
curve: const Interval(0.0, 0.6, curve: Curves.elasticOut),
|
|
);
|
|
_fadeAnim = CurvedAnimation(
|
|
parent: _entranceController,
|
|
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
|
|
);
|
|
_slideAnim = Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero)
|
|
.animate(
|
|
CurvedAnimation(
|
|
parent: _entranceController,
|
|
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
|
|
),
|
|
);
|
|
|
|
Future.delayed(const Duration(milliseconds: 200), () {
|
|
if (mounted) {
|
|
_entranceController.forward();
|
|
// Celebrate only when it's actually special: a real bonus reveal.
|
|
// Route-completion confetti fires from _loadNextOrders once we know no
|
|
// stops remain. A plain mid-route stop gets a calm screen (no confetti)
|
|
// so the rider isn't slowed by fireworks 15 times a shift.
|
|
if (!widget.isCancelled && widget.bonusPoints > 0) {
|
|
_confettiController.play();
|
|
}
|
|
}
|
|
});
|
|
|
|
_loadNextOrders();
|
|
}
|
|
|
|
/// Fires the confetti only when the route is finished (no stops left). A
|
|
/// bonus reveal already celebrates in initState, and cancelled stops never do.
|
|
void _maybeCelebrateRouteComplete() {
|
|
if (!mounted || widget.isCancelled || widget.bonusPoints > 0) return;
|
|
if (_nextOrders.isEmpty) _confettiController.play();
|
|
}
|
|
|
|
Future<void> _loadNextOrders() async {
|
|
try {
|
|
// If parent passed remaining bookings, use those
|
|
if (widget.remainingOrders != null) {
|
|
final live = widget.remainingOrders!
|
|
.where((o) => !stopStatusOf(o).isFinishedPickup)
|
|
.toList();
|
|
setState(() {
|
|
_remainingCount = live.length;
|
|
_nextOrders = live.take(3).toList();
|
|
_loadingNextOrders = false;
|
|
});
|
|
_maybeCelebrateRouteComplete();
|
|
return;
|
|
}
|
|
|
|
// Otherwise fetch from SharedPreferences / API
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
|
|
|
|
if (userId == 0) {
|
|
if (mounted) setState(() => _loadingNextOrders = false);
|
|
return;
|
|
}
|
|
|
|
final provider = PickupProvider();
|
|
final items = await provider.getPickupQueuesPicked();
|
|
|
|
List<Map<String, dynamic>> next = items
|
|
.whereType<Map<String, dynamic>>()
|
|
.toList();
|
|
|
|
// Fallback when the server returns nothing. On the live backend the
|
|
// accepted store is the load-bearing record of bookings the rider has
|
|
// taken on (the backend keeps them as 'miler_assigned'), so use it to
|
|
// surface the remaining stops — completed/cancelled ones are removed from
|
|
// the store on finish, so they won't show here. The mock list is only
|
|
// added on the old demo path.
|
|
if (next.isEmpty) {
|
|
final accepted = await getAcceptedBookings();
|
|
next = [...accepted];
|
|
}
|
|
|
|
// Nothing left is a real answer here — it is what "Return to hub" is for.
|
|
// The debug demo-route fallback that used to sit here is gone with the
|
|
// rest of the mock layer.
|
|
|
|
// Keep only pickups that aren't already completed/cancelled.
|
|
next = next.where((o) => !stopStatusOf(o).isFinishedPickup).toList();
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_remainingCount = next.length;
|
|
_nextOrders = next.take(3).toList();
|
|
_loadingNextOrders = false;
|
|
});
|
|
_maybeCelebrateRouteComplete();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[DONE] Error loading next bookings: $e');
|
|
if (mounted) setState(() => _loadingNextOrders = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_confettiController.dispose();
|
|
_entranceController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Opens the next pickup stop's navigation screen, replacing this completion
|
|
/// screen so the rider can't tap back into it. When no stops remain, returns
|
|
/// to the hub (Bookings tab).
|
|
void _goToNextStop({Map<String, dynamic>? order}) {
|
|
final Map<String, dynamic>? next =
|
|
order ?? (_nextOrders.isNotEmpty ? _nextOrders.first : null);
|
|
if (next != null) {
|
|
// Use the same screen as "Start pickup" so every stop has an identical
|
|
// flow: preview → navigate → arrive → verify → confirm → next stop.
|
|
// Thread the real pickups state through so each stop's completion updates
|
|
// the one live list (removes it, unblocks the following stop).
|
|
Get.off(
|
|
() => _PickupMapScreen(pickup: next, parentState: widget.parentState),
|
|
);
|
|
} else {
|
|
Get.offAll(() => const BottomPage(initialIndex: 1));
|
|
}
|
|
}
|
|
|
|
/// The screen's single surface. With nothing on the page boxed, there is
|
|
/// only one background, and everything — including the pinned CTA bar — is
|
|
/// drawn straight onto it.
|
|
static Color get _pageBg => ColorConstants.daylightSurface;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bool showScratchCard = !widget.isCancelled && widget.bonusPoints > 0;
|
|
|
|
return Scaffold(
|
|
backgroundColor: _pageBg,
|
|
body: Stack(
|
|
children: [
|
|
SafeArea(
|
|
child: showScratchCard
|
|
? _buildScratchCardContent()
|
|
: _buildStandardContent(),
|
|
),
|
|
// Confetti overlay
|
|
Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ConfettiWidget(
|
|
confettiController: _confettiController,
|
|
blastDirectionality: BlastDirectionality.explosive,
|
|
shouldLoop: false,
|
|
numberOfParticles: 30,
|
|
gravity: 0.3,
|
|
emissionFrequency: 0.05,
|
|
colors: [
|
|
ColorConstants.primary,
|
|
ColorConstants.acceptGreen,
|
|
Color(0xFF007AFF),
|
|
Color(0xFFFFB800),
|
|
Color(0xFFAF52DE),
|
|
],
|
|
createParticlePath: drawStar,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Standard Picked up / Cancelled ─────────────────────────────────────────
|
|
//
|
|
// A handoff, not a trophy — and one surface, not five.
|
|
//
|
|
// ── What was wrong with the previous version ──
|
|
//
|
|
// Every group on this screen was a bordered, filled, rounded rectangle: a
|
|
// green banner, a shadowed hero card, a bordered list card with a pill inside
|
|
// it and a filled purple badge inside that. Four containers, three border
|
|
// weights and a shadow, stacked down a page holding about forty words.
|
|
//
|
|
// The cost is not ugliness, it is *ranking*. A border says "this is a thing,
|
|
// separate from its neighbours". Give that emphasis to all four groups and
|
|
// none of them is emphasised: the rider's eye lands on whichever box is
|
|
// biggest rather than on whichever fact matters, and the thing he opened the
|
|
// screen for — where am I going next — has to be found rather than seen.
|
|
//
|
|
// ── What replaces them ──
|
|
//
|
|
// Nothing is boxed. Rank is carried by the things that carry rank for free:
|
|
//
|
|
// • **Type size.** The stop name is 28sp against a 12.5sp status line. That
|
|
// ratio alone answers "what is this screen about" before anything is read.
|
|
// • **Colour weight.** Finished work is grey with a small green tick; the
|
|
// live destination is near-black. Completed information should be legible
|
|
// and ignorable, which is exactly what grey-on-neutral is.
|
|
// • **Whitespace.** A 26pt gap groups more cleanly than a 1px border does,
|
|
// and it costs no visual noise to do it.
|
|
// • **One hairline**, between the hero and the list — the single place where
|
|
// a genuine change of subject happens.
|
|
//
|
|
// The order is the rider's own sentence: you finished that → this is next →
|
|
// then these → press this. Roughly one second, top to bottom, without a
|
|
// single edge to parse.
|
|
Widget _buildStandardContent() {
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
// ── The top margin is not decoration ──
|
|
//
|
|
// This screen has no app bar — there is nowhere to go back to, so
|
|
// there is nothing for one to hold — and `SafeArea` only clears the
|
|
// notch. With 10pt after it the status line began about 34pt down
|
|
// an 844pt page, hard under the clock, and every page in the app
|
|
// that *does* have a bar starts its content around 90pt. So the
|
|
// handoff read as the one screen missing its header rather than as
|
|
// the one screen that does not need one.
|
|
//
|
|
// 34pt puts the first line clear of the system chrome and gives the
|
|
// page a top edge of its own. It is the same trick Uber's post-trip
|
|
// screens use: no bar, but a margin deep enough that the absence
|
|
// reads as deliberate.
|
|
padding: EdgeInsets.fromLTRB(20.w, 34.h, 20.w, 32.h),
|
|
child: FadeTransition(
|
|
opacity: _fadeAnim,
|
|
child: SlideTransition(
|
|
position: _slideAnim,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_statusLine(),
|
|
if (_loadingNextOrders)
|
|
_findingNextStop()
|
|
else if (_nextOrders.isEmpty)
|
|
_routeComplete()
|
|
else ...[
|
|
// Deliberately wider than the 34pt margin above the
|
|
// status line. Equal gaps left that line belonging to
|
|
// neither the top edge nor the hero — it just hung there.
|
|
// Tighter above than below groups it with the top of the
|
|
// page, which is what it is: a footnote on the screen the
|
|
// rider just left, not an introduction to this one.
|
|
SizedBox(height: 42.h),
|
|
_nextStopHero(_nextOrders.first),
|
|
if (_nextOrders.length > 1) _upNext(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
_handoffBar(),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// What just happened, and how much is left — on one grey line.
|
|
///
|
|
/// This was a filled, bordered green banner with a bordered white pill inside
|
|
/// it, about 60pt tall, and it was the loudest object on a screen whose
|
|
/// subject is somewhere else entirely. It is *completed* information: the
|
|
/// rider performed the act himself thirty seconds ago and is not deciding
|
|
/// anything with it. He needs to be able to confirm it, not to be told it.
|
|
///
|
|
/// So the green survives only in the tick — enough to answer "did that
|
|
/// register?" at a glance — and the words drop to the same grey as every
|
|
/// other secondary caption in the app. The count rides the same line, dot
|
|
/// separated, because "delivered" and "9 left" are one thought about the
|
|
/// shift, not two facts needing two containers.
|
|
Widget _statusLine() {
|
|
final isCancelled = widget.isCancelled;
|
|
final method = widget.paymentMethod?.trim() ?? '';
|
|
final done = widget.isDelivery ? 'Delivered' : 'Picked up';
|
|
|
|
final parts = <String>[
|
|
isCancelled ? 'Not completed' : done,
|
|
if (!isCancelled && method.isNotEmpty) method,
|
|
if (_remainingCount > 0)
|
|
'$_remainingCount ${_remainingCount == 1 ? 'stop' : 'stops'} left',
|
|
];
|
|
|
|
// ── It still has to answer "did that register?" ──
|
|
//
|
|
// The reasoning above is right that this is completed information and must
|
|
// not out-rank the next stop. It went one step too far: a 16sp tick beside
|
|
// 12.5sp grey text is the same treatment as every other caption on the
|
|
// screen, so the one line confirming the act the rider just performed
|
|
// looked like metadata about it.
|
|
//
|
|
// A tinted capsule — the app's own state vocabulary, the same one the route
|
|
// chips and the sheet's secondary actions use — gives it an edge and its
|
|
// semantic colour back without giving it size. It is still shorter and
|
|
// quieter than the next-stop hero underneath, which is the ranking that
|
|
// matters.
|
|
final Color tone = isCancelled
|
|
? ColorConstants.errorRed
|
|
: ColorConstants.acceptGreen;
|
|
|
|
return Align(
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.h),
|
|
decoration: BoxDecoration(
|
|
color: tone.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isCancelled ? LucideIcons.circleX : LucideIcons.circleCheck,
|
|
size: 18.sp,
|
|
color: tone,
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Flexible(
|
|
child: Text(
|
|
parts.join(' · '),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.2,
|
|
color: tone,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The next stop, at the size of the decision it is.
|
|
///
|
|
/// No card, no border, no shadow. A hero that needs a box drawn round it to
|
|
/// read as the hero is not yet a hero — and this one does not: at 28sp
|
|
/// against a 12.5sp status line above it and 15sp rows below, it is the only
|
|
/// thing on the screen the eye can land on first.
|
|
///
|
|
/// The stop type moves from a filled purple badge floated to the right into a
|
|
/// dotted label under the address. Same information, ranked correctly: the
|
|
/// rider decides *whether to go* from the name and the address, and only then
|
|
/// asks what he will be doing when he arrives. A saturated chip at the top
|
|
/// asked that question first and in the loudest voice on the screen.
|
|
Widget _nextStopHero(Map<String, dynamic> stop) {
|
|
// The leg, not the payload — see [MilkRun.workingKind].
|
|
final kind = MilkRun.workingKind(stop);
|
|
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
|
|
final address = (stop['pickupaddress'] ?? 'Address not available')
|
|
.toString();
|
|
final kms = (stop['kms'] ?? '').toString();
|
|
final skipped =
|
|
(stop['orderstatus']?.toString().toLowerCase() ?? '') == 'skipped';
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('NEXT STOP', style: _eyebrow),
|
|
SizedBox(height: 10.h),
|
|
Text(
|
|
name,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 28.sp,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.9,
|
|
height: 1.08,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 10.h),
|
|
Text(
|
|
address,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 14.5.sp,
|
|
height: 1.45,
|
|
fontWeight: FontWeight.w500,
|
|
color: ColorConstants.onSurfaceVariant,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 16.h),
|
|
Row(
|
|
children: [
|
|
// The one place the type colour appears, and it appears as a 6pt
|
|
// dot. Enough to separate a delivery from a pickup at a glance
|
|
// across a day of these; not enough to compete with the name.
|
|
Container(
|
|
width: 6.w,
|
|
height: 6.w,
|
|
decoration: BoxDecoration(
|
|
color: kind.accent,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Flexible(
|
|
child: Text(
|
|
kind.titleLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: kind.accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
if (kms.isNotEmpty && kms != '0') ...[
|
|
Text(' · ', style: _metaStyle),
|
|
Text('$kms km away', style: _metaStyle),
|
|
],
|
|
],
|
|
),
|
|
if (skipped) ...[SizedBox(height: 14.h), _retryNote()],
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The one place on this screen that still earns a fill.
|
|
///
|
|
/// It is not a group of related content — it is an exception, and an
|
|
/// exception that contradicts what the rider remembers: he passed this stop
|
|
/// earlier and is being sent back. A tinted strip is how the app marks that
|
|
/// everywhere else, and dropping it to a plain line here would hide the one
|
|
/// fact on the screen he cannot infer from anything around it.
|
|
Widget _retryNote() {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(LucideIcons.rotateCcw, size: 15.sp, color: ColorConstants.warning),
|
|
SizedBox(width: 8.w),
|
|
Expanded(
|
|
child: Text(
|
|
'You skipped this one earlier — this is the retry',
|
|
style: TextStyle(
|
|
fontSize: 12.5.sp,
|
|
height: 1.35,
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorConstants.warning,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Stops after the next one: one list, not a stack of cards.
|
|
///
|
|
/// These are context, never a decision — the rider only ever acts on the stop
|
|
/// in front of him. So they get the quietest treatment that is still tappable:
|
|
/// a numbered circle, two lines of text, a chevron, and a hairline between
|
|
/// rows inset to the text so the column reads as one list rather than as
|
|
/// separate objects.
|
|
///
|
|
/// The single full-width rule above the section is the only border on the
|
|
/// screen. It is there because this is the one point where the subject
|
|
/// genuinely changes — from the stop he is going to, to the ones after it.
|
|
Widget _upNext() {
|
|
final later = _nextOrders.skip(1).toList();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(height: 32.h),
|
|
Container(height: 1, color: ColorConstants.borderSubtle),
|
|
SizedBox(height: 22.h),
|
|
Text(
|
|
'UP NEXT · ${later.length} ${later.length == 1 ? 'STOP' : 'STOPS'}',
|
|
style: _eyebrow,
|
|
),
|
|
SizedBox(height: 6.h),
|
|
for (var i = 0; i < later.length; i++) ...[
|
|
if (i > 0)
|
|
Padding(
|
|
// Inset to the text column, not to the numbers. A rule that runs
|
|
// under the circles cuts the list into slabs; one that starts
|
|
// where the names start reads as a separator inside a single list.
|
|
padding: EdgeInsets.only(left: 39.w),
|
|
child: Container(height: 1, color: ColorConstants.borderSubtle),
|
|
),
|
|
_upNextRow(later[i], i + 2),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _upNextRow(Map<String, dynamic> stop, int position) {
|
|
// The leg, not the payload — see [MilkRun.workingKind].
|
|
final kind = MilkRun.workingKind(stop);
|
|
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
|
|
final address = (stop['pickupaddress'] ?? '').toString();
|
|
|
|
return InkWell(
|
|
onTap: () => _goToNextStop(order: stop),
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 15.h),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 26.w,
|
|
height: 26.w,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: kind.accent.withValues(alpha: 0.10),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Text(
|
|
'$position',
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
fontWeight: FontWeight.w700,
|
|
color: kind.accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 13.w),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.2,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
if (address.isNotEmpty) ...[
|
|
SizedBox(height: 2.h),
|
|
Text(
|
|
address,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5.sp,
|
|
fontWeight: FontWeight.w500,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Icon(
|
|
LucideIcons.chevronRight,
|
|
size: 20.sp,
|
|
color: ColorConstants.secondaryText,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _findingNextStop() {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 78.h),
|
|
child: Center(
|
|
child: Column(
|
|
children: [
|
|
SizedBox(
|
|
width: 22.w,
|
|
height: 22.w,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
ColorConstants.primary,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 14.h),
|
|
Text(
|
|
'Finding your next stop…',
|
|
style: TextStyle(
|
|
fontSize: 13.5.sp,
|
|
fontWeight: FontWeight.w500,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The one moment on this screen that has earned a celebration: the route is
|
|
/// finished. Confetti already fires from [_maybeCelebrateRouteComplete].
|
|
///
|
|
/// Still no card. The green disc is a *mark* — it is the subject of the
|
|
/// message, not a container holding it — which is the line this screen draws
|
|
/// between a shape that is allowed and one that is not.
|
|
Widget _routeComplete() {
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(8.w, 58.h, 8.w, 24.h),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
padding: EdgeInsets.all(16.r),
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.acceptGreen.withValues(alpha: 0.10),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
LucideIcons.warehouse,
|
|
size: 30.sp,
|
|
color: ColorConstants.acceptGreen,
|
|
),
|
|
),
|
|
SizedBox(height: 18.h),
|
|
Text(
|
|
'Every stop done',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 24.sp,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.6,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 8.h),
|
|
Text(
|
|
'Head back to the hub to drop the parcels and hand in any cash '
|
|
'you collected.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
height: 1.5,
|
|
fontWeight: FontWeight.w500,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Pinned CTA.
|
|
///
|
|
/// Same background as the page and a single hairline above it, rather than a
|
|
/// white slab with a shadow. The bar is not a separate surface floating over
|
|
/// the content — it is the bottom of the page with the button held in it, and
|
|
/// on a screen that has just given up every other border it would be the
|
|
/// heaviest edge left if it kept its own.
|
|
Widget _handoffBar() {
|
|
final hasNext = _nextOrders.isNotEmpty;
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 12.h),
|
|
decoration: BoxDecoration(
|
|
color: _pageBg,
|
|
border: Border(
|
|
top: BorderSide(color: ColorConstants.borderSubtle, width: 1),
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: MilerButton(
|
|
label: _loadingNextOrders
|
|
? 'Loading…'
|
|
: hasNext
|
|
? 'Move to next stop'
|
|
// A milk run ends at the last door, not at a depot — see
|
|
// [ServiceProfile.endsAtHub]. Capability, not screen position.
|
|
: (ServiceProfile.active.endsAtHub
|
|
? 'Return to hub'
|
|
: 'Finish my round'),
|
|
icon: hasNext
|
|
? LucideIcons.arrowRight
|
|
: (ServiceProfile.active.endsAtHub
|
|
? LucideIcons.warehouse
|
|
: LucideIcons.house),
|
|
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
|
|
loading: _loadingNextOrders,
|
|
onPressed: _loadingNextOrders ? null : () => _goToNextStop(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Section labels. Small, letterspaced, grey — the quietest way to name a
|
|
/// group, and the reason none of the groups needs an outline.
|
|
TextStyle get _eyebrow => TextStyle(
|
|
// 11 -> 12.5, and less tracking. A letterspaced 11sp all-caps label is the
|
|
// least readable combination in the app, on the screen a rider glances at
|
|
// between two stops.
|
|
fontSize: 12.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.9,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
);
|
|
|
|
TextStyle get _metaStyle => TextStyle(
|
|
fontSize: 13.5.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
);
|
|
|
|
// ── Scratch Card Content ───────────────────────────────────────────────────
|
|
Widget _buildScratchCardContent() {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Spacer(flex: 1),
|
|
|
|
ScaleTransition(
|
|
scale: _circleScaleAnim,
|
|
child: Container(
|
|
width: 68.w,
|
|
height: 68.w,
|
|
// ── Celebration, in the app's own voice ──
|
|
//
|
|
// This was an amber gradient over a 20px coloured glow, in two
|
|
// hex values that appear nowhere else in the product — so the one
|
|
// moment a rider is being congratulated looked like it came from
|
|
// a different app. It stays visually special (it is the only
|
|
// filled disc on any handoff screen, and it still scales in with
|
|
// an elastic curve) but it is now the app's own reward colour,
|
|
// flat, the way every other surface here is.
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.rewardAmber,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(LucideIcons.gift, size: 30.sp, color: Colors.white),
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 16.h),
|
|
FadeTransition(
|
|
opacity: _fadeAnim,
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
'You Won a Bonus!',
|
|
style: TextStyle(
|
|
fontSize: 24.sp,
|
|
fontWeight: FontWeight.w800,
|
|
color: ColorConstants.onSurface,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 6.h),
|
|
Text(
|
|
'Scratch the card to reveal your bonus points',
|
|
style: TextStyle(
|
|
fontSize: 14.sp,
|
|
color: ColorConstants.onSurfaceVariant,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 24.h),
|
|
|
|
// Scratch Card
|
|
FadeTransition(
|
|
opacity: _fadeAnim,
|
|
child: Center(
|
|
child: Container(
|
|
width: 250.w,
|
|
height: 250.w,
|
|
decoration: BoxDecoration(
|
|
color: ColorConstants.pureSurface,
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.08),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
BoxShadow(
|
|
color: const Color(0xFFFFB800).withValues(alpha: 0.15),
|
|
blurRadius: 30,
|
|
offset: const Offset(0, 12),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
child: Scratcher(
|
|
key: _scratcherKey,
|
|
brushSize: 50,
|
|
threshold: 50,
|
|
color: ColorConstants.primary,
|
|
onChange: (value) {},
|
|
onThreshold: () {
|
|
_confettiController.play();
|
|
setState(() {
|
|
_opacity = 1.0;
|
|
_isScratched = true;
|
|
});
|
|
},
|
|
image: Image.asset(
|
|
'assets/images/doormile_icon.png',
|
|
fit: BoxFit.scaleDown,
|
|
width: 100,
|
|
height: 100,
|
|
),
|
|
child: Container(
|
|
width: 250,
|
|
height: 250,
|
|
color: ColorConstants.pureSurface,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
LucideIcons.circleDollarSign,
|
|
size: 64.sp,
|
|
color: const Color(0xFFFFB800),
|
|
),
|
|
SizedBox(height: 10.h),
|
|
Text(
|
|
'${widget.bonusPoints}',
|
|
style: TextStyle(
|
|
fontSize: 52.sp,
|
|
fontWeight: FontWeight.w800,
|
|
color: ColorConstants.onSurface,
|
|
fontFamily: FontConstants.fontFamily,
|
|
letterSpacing: -2,
|
|
),
|
|
),
|
|
Text(
|
|
'Bonus Points',
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
fontWeight: FontWeight.w500,
|
|
color: ColorConstants.onSurfaceVariant,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
if (!_isScratched)
|
|
Padding(
|
|
padding: EdgeInsets.only(top: 16.h),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
LucideIcons.pointer,
|
|
color: ColorConstants.onSurfaceVariant,
|
|
size: 16.sp,
|
|
),
|
|
SizedBox(width: 6.w),
|
|
Text(
|
|
'Scratch the card to reveal',
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
color: ColorConstants.onSurfaceVariant,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const Spacer(flex: 1),
|
|
_buildDoneButton(isEnabled: _isScratched),
|
|
SizedBox(height: 24.h),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// ── One button, not a second implementation of one ──
|
|
///
|
|
/// This was a hand-rolled `AnimatedContainer` with a gradient fill, a drop
|
|
/// shadow, its own radius, its own label size and `disabledFill` used as the
|
|
/// *text* colour when disabled. It is the same action as the button on the
|
|
/// no-bonus branch fifty lines up — "move to the next stop" — so it is the
|
|
/// same button now, and the celebration is carried by the screen around it
|
|
/// rather than by giving the CTA its own costume.
|
|
Widget _buildDoneButton({required bool isEnabled}) {
|
|
final bool hasNext = _nextOrders.isNotEmpty;
|
|
return MilerButton(
|
|
label: hasNext
|
|
? 'Move to next stop'
|
|
: (ServiceProfile.active.endsAtHub
|
|
? 'Return to hub'
|
|
: 'Finish my round'),
|
|
icon: hasNext
|
|
? LucideIcons.arrowRight
|
|
: (ServiceProfile.active.endsAtHub
|
|
? LucideIcons.warehouse
|
|
: LucideIcons.house),
|
|
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
|
|
onPressed: isEnabled ? () => _goToNextStop() : null,
|
|
);
|
|
}
|
|
|
|
Path drawStar(Size size) {
|
|
double degToRad(double deg) => deg * (math.pi / 180.0);
|
|
const numberOfPoints = 5;
|
|
final halfWidth = size.width / 2;
|
|
final externalRadius = halfWidth;
|
|
final internalRadius = halfWidth / 2.5;
|
|
final degreesPerStep = degToRad(360 / numberOfPoints);
|
|
final halfDegreesPerStep = degreesPerStep / 2;
|
|
final path = Path();
|
|
final fullAngle = degToRad(360);
|
|
path.moveTo(size.width, halfWidth);
|
|
for (double step = 0; step < fullAngle; step += degreesPerStep) {
|
|
path.lineTo(
|
|
halfWidth + externalRadius * math.cos(step),
|
|
halfWidth + externalRadius * math.sin(step),
|
|
);
|
|
path.lineTo(
|
|
halfWidth + internalRadius * math.cos(step + halfDegreesPerStep),
|
|
halfWidth + internalRadius * math.sin(step + halfDegreesPerStep),
|
|
);
|
|
}
|
|
path.close();
|
|
return path;
|
|
}
|
|
}
|