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(), }, ), ); } /// Forward navigation, as a shared axis. /// /// ── What was wrong with the old one ── /// /// The incoming page slid 6% from the right and faded, and the page behind it /// did nothing at all — it sat still until it was covered. That is the single /// thing that makes a push feel like a slide projector rather than like /// movement: both surfaces have to travel, or the eye reads the new one as /// pasted over a photograph of the old one. /// /// Now the outgoing page slides the other way and dims as it leaves, so the /// pair share one axis of motion; the incoming page carries a small scale-up /// so it arrives *towards* the viewer rather than merely across; and the fade /// is held back until the travel is a third done, which is what stops the two /// pages muddying into each other mid-transition. /// /// The timing lives on [DmPageRoute]: 420ms in, 340ms back. Going back is /// always quicker than going forward — 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, reverseCurve: DmMotion.enter.flipped, ); final exit = CurvedAnimation( parent: secondaryAnimation, curve: DmMotion.enter, reverseCurve: DmMotion.enter.flipped, ); return SlideTransition( // The page being covered leaves to the left, a third of the distance the // arriving one travels. Parallax: the thing further away moves less. position: Tween( begin: Offset.zero, end: const Offset(-0.09, 0), ).animate(exit), child: FadeTransition( opacity: Tween(begin: 1.0, end: 0.6).animate(exit), child: SlideTransition( position: Tween( begin: const Offset(0.14, 0), end: Offset.zero, ).animate(enter), child: FadeTransition( // Held back: at 0 the page is transparent, at 0.35 of the travel // it is solid. Fading over the whole distance leaves both pages // half-visible through each other for most of it. opacity: CurvedAnimation( parent: animation, curve: const Interval(0, 0.35, curve: Curves.easeOut), ), child: ScaleTransition( scale: Tween(begin: 0.97, end: 1.0).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(); } }