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>? 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 createState() => _PickupsDoneState(); } class _PickupsDoneState extends State with TickerProviderStateMixin { late ConfettiController _confettiController; final GlobalKey _scratcherKey = GlobalKey(); double _opacity = 0.0; bool _isScratched = false; late AnimationController _entranceController; late Animation _circleScaleAnim; late Animation _fadeAnim; late Animation _slideAnim; /// The next few stops, capped for display. List> _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(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 _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> next = items .whereType>() .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? order}) { final Map? 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 = [ 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 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 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( 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; } }