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>
181 lines
5.2 KiB
Dart
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();
|
|
}
|
|
}
|