import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'data/app_config.dart'; import 'state/app_scope.dart'; import 'state/app_state.dart'; import 'ui/screens/auth/login_screen.dart'; import 'ui/screens/shell_screen.dart'; import 'ui/screens/splash_screen.dart'; import 'ui/tokens.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); // What this build is, in the log, on every launch. // // Not a debugging leftover: the offline build and the auto-login build look // identical to the real one from the outside, and the whole reason the first // mock was deleted was that nobody could tell which mode a handset was in. // The Account screen says it too, but a build that cannot get past the login // screen cannot show you the Account screen — which is exactly the session // where you most need to know. Release builds print nothing. if (!kReleaseMode) debugPrint('[BUILD] ${AppConfig.describe}'); // Draw behind the system bars — expected on Android 15+ and the reason the // screens manage their own SafeArea insets. SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, ]); runApp(const DoormileApp()); } /// Doormile — customer app. /// /// Book pickup → Track → Delivered. /// /// `lib/data/doormile_api.dart` is the service surface, and it has exactly one /// implementation: the live customer API. Nothing on screen is invented. class DoormileApp extends StatefulWidget { const DoormileApp({super.key, this.initialState}); /// Injectable for tests. final AppState? initialState; @override State createState() => _DoormileAppState(); } class _DoormileAppState extends State { late final AppState _state = widget.initialState ?? AppState(); @override void initState() { super.initState(); // A returning customer's session outlives the process: the refresh token is // good for 60 days. Look for it before deciding which screen to open. unawaited(_state.restoreSession()); } @override void dispose() { _state.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AppScope( state: _state, child: MaterialApp( title: 'Doormile', debugShowCheckedModeBanner: false, theme: buildDoormileTheme(), home: const _Launch(), // No text-scale clamp: components adapt instead. Rows grow, important // text wraps, and ellipsis is reserved for secondary information. builder: (context, child) { return AnnotatedRegion( value: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, statusBarBrightness: Brightness.light, systemNavigationBarColor: Colors.transparent, systemNavigationBarIconBrightness: Brightness.dark, ), child: child ?? const SizedBox.shrink(), ); }, ), ); } } ThemeData buildDoormileTheme() { const scheme = ColorScheme.light( primary: DmColors.brand, onPrimary: Colors.white, secondary: DmColors.brand, onSecondary: Colors.white, surface: DmColors.surface, onSurface: DmColors.ink, error: DmColors.danger, ); return ThemeData( useMaterial3: true, fontFamily: 'Switzer', colorScheme: scheme, scaffoldBackgroundColor: DmColors.bg, splashFactory: InkSparkle.splashFactory, highlightColor: const Color(0x0A17110F), textSelectionTheme: const TextSelectionThemeData( cursorColor: DmColors.brand, selectionColor: Color(0x338F0F06), selectionHandleColor: DmColors.brand, ), textTheme: const TextTheme().apply( bodyColor: DmColors.ink, displayColor: DmColors.ink, ), dividerTheme: const DividerThemeData( color: DmColors.hairline, thickness: 1, space: 1, ), pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.android: _DmPageTransitions(), TargetPlatform.iOS: _DmPageTransitions(), TargetPlatform.macOS: _DmPageTransitions(), TargetPlatform.windows: _DmPageTransitions(), TargetPlatform.linux: _DmPageTransitions(), }, ), ); } /// Navigation, as a vertical axis. /// /// ── What was wrong with the old one ── /// /// It was horizontal. A push slid the incoming page 14% in from the right and /// nudged the page behind it left; going back ran that in reverse, so the /// screen the customer was leaving slid off to the **right**. Two things are /// wrong with that here. /// /// The first is that it is the system back gesture's direction. On Android the /// predictive-back swipe and on iOS the edge swipe both drag horizontally, so a /// page that also leaves horizontally under a button press reads as a gesture /// the customer did not make. /// /// The second is what these pages *are*. Every push in this app is a layer over /// the one before it — the booking form over Home, the map pin over the form, /// tracking over an order. A layer arrives from below and falls away below; it /// does not slide in from beside the thing it is covering. /// /// ── What it is ── /// /// The incoming page rises from the bottom edge and covers the screen. The page /// it covers stays exactly where it is and dims, because it has not moved — it /// has been covered, and pretending otherwise was the horizontal version's /// parallax trick solving a problem a full-travel cover does not have. /// /// Going back, the top page falls straight down off the bottom and the page /// underneath comes back up to full strength. Top to bottom, out of the way. /// /// The travel is the full height rather than a token 14%, which is what makes /// the direction legible at all: a page that moves 55 points and fades has no /// direction a customer can name. The timing lives on [DmPageRoute] — 420ms in, /// 340ms back. Going back is always quicker than going forward, because the /// customer already knows what is behind them. class _DmPageTransitions extends PageTransitionsBuilder { const _DmPageTransitions(); @override Widget buildTransitions( PageRoute route, BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, ) { final enter = CurvedAnimation( parent: animation, curve: DmMotion.enter, // Flipped on the way back: the emphasised decelerate leaves quickly and // settles slowly, and a page falling away wants the same shape read from // the other end — off the bottom edge decisively, not trailing. reverseCurve: DmMotion.enter.flipped, ); final exit = CurvedAnimation( parent: secondaryAnimation, curve: DmMotion.enter, reverseCurve: DmMotion.enter.flipped, ); return FadeTransition( // This page, being covered by the one above it. Not to nothing: at 0.5 // the page behind is still legibly itself, which is what tells the // customer the thing on top is a layer and not a new place. opacity: Tween(begin: 1.0, end: 0.5).animate(exit), child: SlideTransition( position: Tween( begin: const Offset(0, 1), end: Offset.zero, ).animate(enter), child: child, ), ); } } /// What the app opens on. /// /// Three states, and the middle one is the reason this widget exists: until the /// stored session has been looked for we do not yet know whether this customer /// is signed in, and showing them a login form that vanishes a moment later is /// worse than showing them nothing. /// /// That middle state used to be a blank canvas — correct, and a hole where the /// app's first impression goes. It is [SplashScreen] now, which owns both the /// wait and its own minimum, and tells this widget when it is finished. The /// native window background carries the same mark from process start, so the /// handover into Flutter is invisible. class _Launch extends StatefulWidget { const _Launch(); @override State<_Launch> createState() => _LaunchState(); } class _LaunchState extends State<_Launch> { bool _splashDone = false; @override Widget build(BuildContext context) { final app = AppScope.of(context); if (!_splashDone) { return SplashScreen( ready: !app.restoringSession, onDone: () => setState(() => _splashDone = true), ); } return app.isSignedIn ? const ShellScreen() : const LoginScreen(); } }