import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../data/models.dart'; import '../../state/app_scope.dart'; import '../format.dart'; import '../tokens.dart'; import '../widgets/book_orb.dart'; import '../widgets/chrome.dart'; import '../widgets/misc.dart'; import 'booking/booking_routes.dart'; import 'booking/send_screen.dart'; import 'shell_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; final firstRun = app.orders.isEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ DmBrandHeader( eyebrow: 'Welcome, ${_firstName(app.customer?.name)}', title: firstRun ? 'Send your first parcel' : 'Send a parcel', action: InkResponse( onTap: () => ShellScreen.of(context)?.goToTab(2), radius: 26, child: DmAvatar( initials: initialsOf(app.customer?.name), onBrand: true, ), ), // The address is the one fact every booking starts from, so it sits // in the header as a live value the customer can change in place — // not as the first question of the flow. child: DmHeaderTrack( leading: const DmLiveDot(size: 6, color: Colors.white), trailing: const Icon( LucideIcons.chevronDown, size: 16, color: DmColors.onBrand3, ), onTap: () async { final place = await showPlaceSearchSheet(context); if (place != null && context.mounted) app.setPickup(place); }, child: Text( app.pickup?.title ?? 'Set a pickup address', maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.cardTitle.copyWith(color: Colors.white), ), ), ), 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: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB( DmSpace.pad, 16, DmSpace.pad, 28, ), children: [ const SizedBox(height: 6), DmBookOrb( onTap: _book, held: _handingOver, // A returning customer knows what BOOK does. The line is // for the first run only, and one line is enough to say // what kind of app this is. caption: firstRun ? 'A Miler collects from your door.' : null, ).animate().fadeIn(duration: 420.ms).scale( begin: const Offset(0.82, 0.82), end: const Offset(1, 1), duration: 620.ms, // A shade of overshoot, so it arrives rather than // appears. curve: Curves.easeOutBack, ), if (active != null) Padding( padding: const EdgeInsets.only(top: 22), child: _LiveRow(booking: active), ).animate().fadeIn(duration: 380.ms, delay: 160.ms).slideY( begin: 0.12, curve: DmMotion.ease, ), if (firstRun) const _HowItWorks(), ], ), ), ), ), ), ], ); } static String _firstName(String? name) { final first = (name ?? '').trim().split(RegExp(r'\s+')).first; return first.isEmpty ? 'there' : first; } } /// The live booking, as one row. /// /// This was a card with four cells. A customer glancing at Home wants two /// things from it — *what is happening* and *how long* — and everything else it /// carried was available one tap away on the screen this row opens. The route, /// the reference and the package count all went back there. class _LiveRow extends StatelessWidget { const _LiveRow({required this.booking}); final Booking booking; @override Widget build(BuildContext context) { final app = AppScope.read(context); final slot = app.slotById(booking.slotId); // One supporting line, chosen by what the customer can act on right now: // before the handover that is when the Miler is coming, after it that is // where the parcel is going. final support = switch (booking.stage) { JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay when slot != null => '${slot.day} · ${slot.window}', JourneyStage.arrived => 'At your door now', _ => booking.isMultiDestination ? 'To ${booking.destinations.length} destinations' : 'To ${booking.destination.label}', }; return Semantics( button: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { app.openTracking(booking.reference); Navigator.of(context).push( bookingRoute( BookingRoutes.tracking, (_) => const TrackingScreen(), ), ); }, child: Container( constraints: const BoxConstraints(minHeight: 64), padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), decoration: BoxDecoration( color: DmColors.brandSoft, borderRadius: DmRadius.all(DmRadius.md), border: Border.all(color: DmColors.brandLine), ), child: Row( children: [ const DmLiveDot(size: 7), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( booking.stage.milestoneLabel, style: DmText.cardTitle.copyWith( color: DmColors.brandPress, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( support, style: DmText.small, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 8), const Icon( LucideIcons.chevronRight, size: 18, color: DmColors.brand, ), ], ), ), ), ); } } /// How it works, on first run only. /// /// This was a bordered card holding three numbered rows, each with a subtitle. /// It is a timeline now — glyph, hairline, title — and the subtitles are gone: /// they described the step the title had already named, on the one screen where /// a new customer is least willing to read. class _HowItWorks extends StatelessWidget { const _HowItWorks(); static const _steps = [ (LucideIcons.mapPin, 'Tell us where it goes'), (LucideIcons.clock3, 'Pick a two-hour window'), (LucideIcons.packageCheck, 'Hand it over and track'), ]; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 30), for (var i = 0; i < _steps.length; i++) _Step( icon: _steps[i].$1, title: _steps[i].$2, last: i == _steps.length - 1, ), ], ).animate(delay: 320.ms).fadeIn(duration: 520.ms).slideY( begin: 0.06, curve: DmMotion.ease, ); } } class _Step extends StatelessWidget { const _Step({required this.icon, required this.title, required this.last}); final IconData icon; final String title; final bool last; @override Widget build(BuildContext context) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // The rail: a washed disc per step, joined by a hairline that runs // the height of that step's text. Sized by [IntrinsicHeight] so the // connector always meets the next disc, whatever the text wraps to. SizedBox( width: 34, child: Column( children: [ Container( width: 34, height: 34, decoration: const BoxDecoration( shape: BoxShape.circle, color: DmColors.brandSoft, ), child: Icon(icon, size: 15, color: DmColors.brand), ), if (!last) const Expanded( child: SizedBox( width: 1, child: ColoredBox(color: DmColors.border), ), ), ], ), ), const SizedBox(width: 14), Expanded( child: Padding( padding: EdgeInsets.only(top: 8, bottom: last ? 0 : 20), child: Text(title, style: DmText.cardTitle), ), ), ], ), ); } }