Files
doormile_milderapp/lib/views/Dashboard/pickups/done.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

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;
}
}