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>
This commit is contained in:
338
lib/main.dart
338
lib/main.dart
@@ -1,186 +1,180 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/features/auth/screens/onboarding_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/login_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/signup_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/otp_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/pin_screen.dart';
|
||||
import 'package:doormile/features/dashboard/screens/main_shell.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_pickup_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/parcel_details_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/service_price_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_preview_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_success_screen.dart';
|
||||
import 'package:doormile/features/tracking/screens/my_orders_screen.dart';
|
||||
import 'package:doormile/features/tracking/screens/live_tracking_screen.dart';
|
||||
import 'package:doormile/features/tracking/screens/shipment_journey_screen.dart';
|
||||
import 'package:doormile/features/support/screens/help_support_screen.dart';
|
||||
import 'package:doormile/features/dashboard/screens/saved_addresses_screen.dart';
|
||||
import 'package:doormile/features/support/screens/chat_screen.dart';
|
||||
import 'package:doormile/shared/models/models.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() async {
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
} catch (e) {
|
||||
debugPrint('Firebase initialization error: $e');
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
final initialRoute = (token != null && token.isNotEmpty) ? Routes.home : Routes.onboarding;
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
));
|
||||
runApp(DoormileApp(initialRoute: initialRoute));
|
||||
}
|
||||
|
||||
class Routes {
|
||||
static const splash = '/';
|
||||
static const onboarding = '/onboarding';
|
||||
static const login = '/login';
|
||||
static const signup = '/signup';
|
||||
static const otp = '/otp';
|
||||
static const enterPin = '/enter-pin';
|
||||
static const setPin = '/set-pin';
|
||||
static const resetPin = '/reset-pin';
|
||||
static const home = '/home';
|
||||
static const bookingPickup = '/booking/pickup';
|
||||
static const parcelDetails = '/booking/details';
|
||||
static const servicePrice = '/booking/service';
|
||||
static const bookingPreview = '/booking/preview';
|
||||
static const bookingSuccess = '/booking/success';
|
||||
static const myOrders = '/orders';
|
||||
static const liveTracking = '/tracking/live';
|
||||
static const shipmentJourney = '/tracking/journey';
|
||||
static const help = '/help';
|
||||
static const addresses = '/addresses';
|
||||
static const chat = '/chat';
|
||||
}
|
||||
|
||||
class DoormileApp extends StatelessWidget {
|
||||
final String initialRoute;
|
||||
const DoormileApp({super.key, required this.initialRoute});
|
||||
@override
|
||||
void dispose() {
|
||||
_state.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => AppState(),
|
||||
child: _buildApp(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildApp() {
|
||||
return MaterialApp(
|
||||
title: 'Doormile',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
initialRoute: initialRoute,
|
||||
onGenerateRoute: (settings) {
|
||||
Widget page;
|
||||
switch (settings.name) {
|
||||
case Routes.onboarding:
|
||||
page = const OnboardingScreen();
|
||||
break;
|
||||
case Routes.login:
|
||||
page = const LoginScreen();
|
||||
break;
|
||||
case Routes.signup:
|
||||
page = const SignupScreen();
|
||||
break;
|
||||
case Routes.otp:
|
||||
page = const OtpScreen();
|
||||
break;
|
||||
case Routes.enterPin:
|
||||
page = const PinScreen(mode: PinMode.enter);
|
||||
break;
|
||||
case Routes.setPin:
|
||||
page = const PinScreen(mode: PinMode.set);
|
||||
break;
|
||||
case Routes.resetPin:
|
||||
page = const PinScreen(mode: PinMode.reset);
|
||||
break;
|
||||
case Routes.home:
|
||||
page = const MainShell();
|
||||
break;
|
||||
case Routes.bookingPickup:
|
||||
page = const BookingPickupScreen();
|
||||
break;
|
||||
case Routes.parcelDetails:
|
||||
page = const ParcelDetailsScreen();
|
||||
break;
|
||||
case Routes.servicePrice:
|
||||
page = const ServicePriceScreen();
|
||||
break;
|
||||
case Routes.bookingPreview:
|
||||
page = const BookingPreviewScreen();
|
||||
break;
|
||||
case Routes.bookingSuccess:
|
||||
page = const BookingSuccessScreen();
|
||||
break;
|
||||
case Routes.myOrders:
|
||||
page = const MyOrdersScreen();
|
||||
break;
|
||||
case Routes.liveTracking:
|
||||
page = LiveTrackingScreen(order: settings.arguments as Order?);
|
||||
break;
|
||||
case Routes.shipmentJourney:
|
||||
page = ShipmentJourneyScreen(order: settings.arguments as Order?);
|
||||
break;
|
||||
case Routes.help:
|
||||
page = const HelpSupportScreen();
|
||||
break;
|
||||
case Routes.addresses:
|
||||
page = const SavedAddressesScreen();
|
||||
break;
|
||||
case Routes.chat:
|
||||
final chatArgs = settings.arguments as Map<String, String?>? ?? {};
|
||||
page = Builder(
|
||||
builder: (context) {
|
||||
final appState = Provider.of<AppState>(context, listen: false);
|
||||
return ChatScreen(
|
||||
riderName: chatArgs['milerName'] ?? 'Rider',
|
||||
bookingId: chatArgs['bookingId'] ?? '',
|
||||
authToken: appState.authToken,
|
||||
);
|
||||
},
|
||||
);
|
||||
break;
|
||||
default:
|
||||
page = const MainShell();
|
||||
}
|
||||
return PageRouteBuilder(
|
||||
settings: settings,
|
||||
pageBuilder: (_, __, ___) => page,
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, 0.02), end: Offset.zero)
|
||||
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
);
|
||||
},
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selection colour helper used by text fields.
|
||||
final selectionTheme = const TextSelectionThemeData(
|
||||
selectionColor: AppColors.primaryFixed,
|
||||
cursorColor: AppColors.primary,
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user