── Navigation ── Pages arrived from the right and left to the right. Right is the direction the system back gesture drags, so a page leaving under a button press read as a swipe nobody made — and every push in this app is a layer over the one before it, which arrives from below. The axis is vertical now: the incoming page rises and covers, the page underneath stays put and dims, and back drops the top page off the bottom. Full travel rather than the old 14% nudge, which is what makes the direction nameable. test/page_transition_test.dart holds it. ── Home ── The pickup pill is back under the greeting. It was cut on the argument that the form below already carried the pickup; the form is four hundred points down the screen, and a location you can only see by scrolling to a form is a location you do not trust. It shows the locality while the form keeps the door, so the screen answers one question once, and it now has a resolving state — it used to print the last place you were, confidently, while the fix was still in flight. The sphere's caption was falling off the screen. Adding the pill pushed it out of the scroll viewport, silently, with no overflow warning — the fourth time Home growing has clipped something under the sphere. DmBookOrb.field is no longer a constant: the glow gives way to whatever is left, the sphere never does. ── 74 points of dead canvas, on every root ── Home, Orders and Account each cleared the floating tab bar by `MediaQuery.paddingOf(context).bottom + 74`, on the reasoning that the inset is the home indicator and 74 is the bar above it. Scaffold with extendBody replaces the body's bottom padding with the bar's whole laid-out height — measured, a 34pt inset and this bar hand the body 106. They were clearing the bar, then clearing it again. One helper now, dmTabBarClear(). ── One journey, one vocabulary ── Review said ORIGIN/DESTINATION; Home's form and tracking say PICKUP/DROP. Three screens a customer walks in order. The tracking card had already written down the argument; the booking screen hadn't followed. The rail's first milestone read "Booking created" directly above "Order created" — down the rail: created, created, in transit. It is "Pickup booked", which is what JourneyStage.milestoneLabel has called that stage all along on Orders and on Home's live row. ── The launcher icon ── tool/icons.py renders all twenty-eight files from one master, because the failure mode of exporting by hand is nineteen replaced and nine left on the old mark, at the density nobody checks. The master is a bare mark on a field, so it cannot be dropped into Android's adaptive canvas whole: crimson is the background, the white mark is the foreground, and it is sized by its reach from the canvas centre so a round mask cannot take the arrow tip. Measured on the built file: 63.7dp against a 66dp safe zone. A real monochrome layer replaces the colour foreground that was standing in for one — the system tints by alpha, so that themed as a blob. ic_launcher_round.png exists at last; minSdk is 24 and android:roundIcon had nothing to resolve to below API 26. The artwork is flattened (the source vignettes over ten values of red) and its horizontal offset is placed deliberately rather than inherited — geometric centring made the mark lean, because the arrow carries the bounding box right while contributing almost none of the ink. ── Bundle ── pubspec declared assets/images/ as a folder, which shipped the 974 KB icon master to every customer for a file no code opens. Named explicitly now.
255 lines
8.6 KiB
Dart
255 lines
8.6 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(),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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<T>(
|
|
PageRoute<T> route,
|
|
BuildContext context,
|
|
Animation<double> animation,
|
|
Animation<double> 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();
|
|
}
|
|
}
|