Files
doormile_customer_app/lib/main.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

254 lines
8.2 KiB
Dart

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<DoormileApp> createState() => _DoormileAppState();
}
class _DoormileAppState extends State<DoormileApp> {
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<SystemUiOverlayStyle>(
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<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> 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();
}
}