Files
doormile_customer_app/lib/main.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

181 lines
5.2 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/tokens.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// 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: 'Manrope',
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 slides in from the right and fades, matching the
/// prototype's screen transition.
class _DmPageTransitions extends PageTransitionsBuilder {
const _DmPageTransitions();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween(
begin: const Offset(0.06, 0),
end: Offset.zero,
).animate(curved),
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. The Android 12 splash is still up for most
/// of it.
class _Launch extends StatelessWidget {
const _Launch();
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
if (app.restoringSession) {
return const Scaffold(backgroundColor: DmColors.canvas, body: SizedBox());
}
return app.isSignedIn ? const ShellScreen() : const LoginScreen();
}
}