import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_animate/flutter_animate.dart'; import '../../data/models.dart'; import '../../state/app_scope.dart'; import '../tokens.dart'; import 'shell_screen.dart'; import '../format.dart'; import '../widgets/cards.dart'; import '../widgets/book_orb.dart'; import '../widgets/misc.dart'; import 'booking/booking_routes.dart'; import 'booking/send_screen.dart'; import 'sheets/destination_sheet.dart'; import 'sheets/place_search_sheet.dart'; import 'sheets/window_sheet.dart'; import 'tracking_screen.dart'; /// Home — two questions, and nothing else on the screen. /// /// *Can I book?* The sphere. /// *Is anything happening?* One row, when there is. /// /// ── What was removed ── /// /// A two-line caption under the sphere that a returning customer had read a /// hundred times; a four-cell card for the live booking carrying a status pill, /// a reference, a route rail, a courier sentence, a window and a Track button — /// six facts where the customer wanted two; and a three-row explainer with a /// subtitle under every row. /// /// The caption is first-run only now and one line. The live booking is one row. /// The explainer lost its subtitles. Home went from roughly sixty words to /// under twenty for a new customer, and to five for a returning one. class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { /// True while the destination sheet is up. /// /// Home does not just sit behind the scrim — it scales back a little and /// fades, so the sheet reads as having been pushed up by the sphere rather /// than as a new screen that arrived over the top of an unrelated one. bool _handingOver = false; /// BOOK. The one action on this screen, and the whole flow's front door. /// /// Two questions are asked here, as sheets over Home, before any form: /// /// where is it going? → when shall we collect? → the details /// /// Both used to be answered somewhere else. The destination was a strip of /// cards halfway down the form; the window was picked *for* the customer, /// silently, the moment they chose a city — they arrived at a booking with a /// time on it that they had never agreed to. A pickup window is a promise /// about somebody's afternoon, and it is not ours to assume. /// /// Dismissing the window sheet is allowed: the send screen carries "Choose a /// window" in the footer and its button stays disabled until there is one. Future _book() async { final app = AppScope.read(context); final navigator = Navigator.of(context); setState(() => _handingOver = true); try { final places = await showDestinationSheet(context); if (places == null || places.isEmpty || !mounted) return; app.startBooking(); app.setDestinations(places); await showWindowSheet(context); if (!mounted) return; await navigator.push( bookingRoute(BookingRoutes.send, (_) => const SendScreen()), ); } finally { if (mounted) setState(() => _handingOver = false); } } @override Widget build(BuildContext context) { final app = AppScope.of(context); final active = app.activeBookings.isEmpty ? null : app.activeBookings.first; // The sphere's own field, floored so a very short window leaves it a // sphere rather than a slot. It is a *minimum*: the scroll view hands it // whatever is left once the card below has taken what it needs. final orbHeight = math.max(300.0, MediaQuery.sizeOf(context).height * 0.38); return SafeArea( bottom: false, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // No wordmark bar here. // // Orders and Account open on a title and want a bar over it. Home // opens on the customer's own name with one object under it, and a // second header above that made the screen start twice. // ── Greeting, then where from ── // // "Hello, Joe ●" with the brand dot, the avatar opposite it, and the // address as a bordered pill under both. With the wordmark bar gone // this is the top of the screen, so it takes the inset the bar used // to hold. Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Row( children: [ Flexible( child: Text( 'Hello, ${_firstName(app.customer?.name)}', style: DmText.headerTitle.copyWith(fontSize: 26), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), Container( width: 6, height: 6, decoration: const BoxDecoration( color: DmColors.brand, shape: BoxShape.circle, ), ), ], ), ), const SizedBox(width: 12), InkResponse( onTap: () => ShellScreen.of(context)?.goToTab(2), radius: 26, child: DmAvatar( initials: initialsOf(app.customer?.name), size: 40, badge: active != null, ), ), ], ), const SizedBox(height: 12), _PickupLine( title: app.pickup?.title ?? 'Set a pickup address', onTap: () async { final place = await showPlaceSearchSheet(context); if (place != null && context.mounted) app.setPickup(place); }, ), ], ), ), Expanded( child: AnimatedScale( scale: _handingOver ? 0.96 : 1, duration: DmMotion.base, curve: DmMotion.ease, child: AnimatedOpacity( opacity: _handingOver ? 0.45 : 1, duration: DmMotion.base, curve: DmMotion.ease, child: RefreshIndicator( color: DmColors.brand, backgroundColor: DmColors.surface, onRefresh: () async { await Future.wait([ app.detectPickupLocation(), app.refreshOrders(), ]); }, child: LayoutBuilder( builder: (context, box) => ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB( DmSpace.pad, 0, DmSpace.pad, 0, ), children: [ ConstrainedBox( constraints: BoxConstraints( minHeight: math.max(orbHeight, box.maxHeight), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ DmBookOrb(onTap: _book, held: _handingOver) .animate() .fadeIn(duration: 420.ms) .scale( begin: const Offset(0.82, 0.82), end: const Offset(1, 1), duration: 620.ms, curve: Curves.easeOutBack, ), const SizedBox(height: 20), // The promise, as a chip. It was a sentence under // the sphere on first run and nothing afterwards; // a returning customer is the one who most wants // to know a Miler is three minutes away. const _PromiseChip( icon: LucideIcons.zap, label: '3 min pickup', ).animate().fadeIn( duration: 380.ms, delay: 220.ms, ), ], ), ), ], ), ), ), ), ), ), // ── The foot: what is running, and nothing else ── // // A "quick dispatch" row of shortcut cities lived here and was // removed. It was a second way to start the one thing this screen // starts, and it cost the sphere the space that makes it read as an // object floating in a page rather than as a button in a stack. // // The card sits outside the scroll view, so the sphere is centred in // what is actually left rather than in a viewport pretending the // card is not there. if (active != null) Padding( padding: const EdgeInsets.fromLTRB( DmSpace.pad, 14, DmSpace.pad, 6, ), child: _LiveCard(booking: active), ) .animate() .fadeIn(duration: 380.ms, delay: 160.ms) .slideY(begin: 0.12, curve: DmMotion.ease), // Clears the floating tab bar, which the shell stacks over the body. // // Measured, not guessed. A fixed 74 was right on the 844pt golden // and eight points short on a device with a home indicator, where // the bar is its own 58 plus 10 of padding plus a 34pt inset — so // the pill sat on top of the live card. SizedBox(height: MediaQuery.paddingOf(context).bottom + 74), ], ), ); } static String _firstName(String? name) { final first = (name ?? '').trim().split(RegExp(r'\s+')).first; return first.isEmpty ? 'there' : first; } } /// The live booking, at the foot of Home. /// /// ── The loader went, and the card grew ── /// /// It carried a thin filled line under the destination — a progress bar across /// the five customer milestones. It was the one fact that cannot be written as /// a word, and it still read as a *loader*: a bar that fills is the shape an /// app uses to say "wait", and a parcel two days into a journey is not /// something the customer is waiting through. The rail on tracking says the /// same thing with five labelled nodes and no ambiguity. /// /// What the space bought is size. This is the only thing on Home besides the /// sphere and it was a 90pt strip — a chip, a place and a courier compressed /// into three tight lines. The destination is set at display weight with its /// promise under it now, a rule separates the journey from the person carrying /// it, and the arrow is a target rather than a glyph. class _LiveCard extends StatelessWidget { const _LiveCard({required this.booking}); final Booking booking; @override Widget build(BuildContext context) { final app = AppScope.read(context); final slot = app.slotById(booking.slotId); final miler = booking.miler ?? booking.deliveryAgent; // The district alone. "Chennai, Tamil Nadu" shares its row with the state // and the reference, and on a 402pt device it rendered "Chenna…" — the // half of the name that identifies nothing. final place = booking.isMultiDestination ? '${booking.destinations.length} destinations' : booking.destination.shortLabel; // The promise, in the order a customer can act on it: how long, then // where they are, then what was agreed. final promise = switch (booking.stage) { JourneyStage.arrived => 'At your door now', _ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 => 'Arriving in about ${booking.milerEtaMinutes} min', JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay when slot != null => '${slot.day} · ${slot.window}', _ when booking.expectedDelivery != null => 'Expected ${booking.expectedDelivery}', _ => booking.stage.milestoneLabel, }; return DmCard( raised: true, onTap: () { app.openTracking(booking.reference); Navigator.of(context).push( bookingRoute( BookingRoutes.tracking, (_) => const TrackingScreen(), ), ); }, children: [ DmCardCell( padding: const EdgeInsets.fromLTRB(18, 17, 18, 18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ const DmLiveDot(size: 7), const SizedBox(width: 9), Flexible( child: Text( booking.stage.chipLabel.toUpperCase(), style: DmText.eyebrow.copyWith( fontSize: 10, letterSpacing: 0.9, color: DmColors.brand, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 10), Flexible( child: Text( booking.displayId, style: DmText.monoSm.copyWith(color: DmColors.ink3), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 14), Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( place, style: DmText.headerTitle.copyWith(fontSize: 26), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), Text( promise, style: DmText.body.copyWith( fontSize: 14.5, color: DmColors.ink3, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 12), // A disc, not a bare chevron. It is the only affordance on // the card and it sits against 26pt of type, where a 17pt // glyph on its own disappears. Container( width: 44, height: 44, alignment: Alignment.center, decoration: const BoxDecoration( color: DmColors.brandSoft, shape: BoxShape.circle, ), child: const Icon( LucideIcons.arrowRight, size: 19, color: DmColors.brand, ), ), ], ), if (miler != null) ...[ const SizedBox(height: 16), const Divider( height: 1, thickness: 1, color: DmColors.hairline, ), const SizedBox(height: 14), Row( children: [ DmAvatar(initials: miler.initials, size: 38), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( miler.name, style: DmText.cardTitle.copyWith(fontSize: 14.5), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( miler.vehicle, style: DmText.monoSm.copyWith(color: DmColors.ink3), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ], ], ), ), ], ); } } /// Where the Miler is coming to, under the greeting. /// /// ── Box → line → pill → line → row → pill ── /// /// It has been most shapes. The one it has landed on is the reference's: a /// small bordered pill holding a brand pin, the address, and a chevron-down. /// /// The pill is right *here* and was wrong before because the screen changed /// around it. On a page whose only other objects were a name and a sphere it /// was a container built to hold one line of text. On a page that now opens /// with a wordmark bar and ends with a floating one, it is the middle term — /// and the chevron-down is the honest glyph, because what it opens is a /// chooser, not another screen. class _PickupLine extends StatelessWidget { const _PickupLine({required this.title, required this.onTap}); final String title; final VoidCallback onTap; @override Widget build(BuildContext context) { return Semantics( button: true, label: 'Pickup from $title. Change', child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Align( alignment: Alignment.centerLeft, child: Container( constraints: const BoxConstraints(minHeight: 40, maxWidth: 300), padding: const EdgeInsets.fromLTRB(11, 8, 11, 8), decoration: BoxDecoration( color: DmColors.surface, borderRadius: DmRadius.all(DmRadius.pill), border: Border.all(color: DmColors.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(LucideIcons.mapPin, size: 15, color: DmColors.brand), const SizedBox(width: 8), Flexible( child: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.cardTitle.copyWith(fontSize: 14), ), ), const SizedBox(width: 6), const Icon( LucideIcons.chevronDown, size: 15, color: DmColors.ink4, ), ], ), ), ), ), ); } } /// The promise under the sphere: a glyph, a figure, a tint. /// /// It was a full sentence — "A Miler collects from your door." — shown on the /// first run only. That is the app explaining itself to somebody who has /// already pressed nothing; what a customer actually wants before they commit /// is how long they will wait, and that is worth saying every time. class _PromiseChip extends StatelessWidget { const _PromiseChip({required this.icon, required this.label}); final IconData icon; final String label; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(11, 7, 13, 8), decoration: BoxDecoration( color: DmColors.surface, borderRadius: DmRadius.all(DmRadius.pill), boxShadow: DmShadow.card, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 13, color: DmColors.brand), const SizedBox(width: 7), Text( label, style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink), ), ], ), ); } }