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
254 lines
8.2 KiB
Dart
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();
|
|
}
|
|
}
|