first commit
This commit is contained in:
979
lib/views/Dashboard/pickups/done.dart
Normal file
979
lib/views/Dashboard/pickups/done.dart
Normal file
@@ -0,0 +1,979 @@
|
||||
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',
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
isCancelled ? Icons.cancel_rounded : Icons.check_circle_rounded,
|
||||
size: 16.sp,
|
||||
color: isCancelled
|
||||
? ColorConstants.errorRed
|
||||
: ColorConstants.acceptGreen,
|
||||
),
|
||||
SizedBox(width: 7.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
parts.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
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) {
|
||||
final kind = stopKindOf(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.w800,
|
||||
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.w700,
|
||||
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(
|
||||
Icons.replay_rounded,
|
||||
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.w700,
|
||||
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) {
|
||||
final kind = stopKindOf(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: 11.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
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.w700,
|
||||
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(
|
||||
Icons.chevron_right_rounded,
|
||||
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.w600,
|
||||
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(
|
||||
Icons.warehouse_rounded,
|
||||
size: 30.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
Text(
|
||||
'Every stop done',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
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'
|
||||
: 'Return to hub',
|
||||
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
|
||||
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(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.3,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
);
|
||||
|
||||
TextStyle get _metaStyle => TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
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(
|
||||
Icons.card_giftcard_rounded,
|
||||
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.w900,
|
||||
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(
|
||||
Icons.monetization_on_rounded,
|
||||
size: 64.sp,
|
||||
color: const Color(0xFFFFB800),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
'${widget.bonusPoints}',
|
||||
style: TextStyle(
|
||||
fontSize: 52.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
letterSpacing: -2,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Bonus Points',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_isScratched)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16.h),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.touch_app_rounded,
|
||||
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' : 'Return to hub',
|
||||
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user