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:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

View File

@@ -0,0 +1,440 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import '../../tokens.dart';
import '../../widgets/chrome.dart';
/// Shared chrome for the auth screens: a brand panel at the top, a white sheet
/// below holding the form.
///
/// Auth is the only place the app goes full-brand — it sets the tone once, and
/// the product itself stays quiet after that.
/// Shared chrome for the auth screens: a full-bleed brand block, and a rounded
/// sheet lifted over it holding the form.
///
/// The frame matches the Miler app's onboarding, deliberately — the two apps
/// are the same company arriving from two directions, and a customer who has
/// seen a Miler at the door should recognise the doorway. One block across sign
/// in, sign up and verify, so the three read as one entrance rather than three
/// forms that happen to follow each other.
///
/// Content is **top-anchored, not centred**: the question and its input sit
/// high, where the keyboard cannot shove them around. The banner gives back
/// half its height the moment a field takes focus, which is what fills the
/// screen at rest without stealing room while someone is typing.
class AuthScaffold extends StatelessWidget {
const AuthScaffold({
super.key,
this.title,
this.subtitle,
required this.children,
this.footer,
this.onBack,
this.heroTitle,
this.heroSub,
this.compactBanner = false,
this.badge = true,
});
/// Heading inside the white sheet, above the form. Null on the entry screen,
/// where the headline is [heroTitle] and sits on the crimson instead.
final String? title;
final String? subtitle;
final List<Widget> children;
/// Pinned below the form, above the safe area.
final Widget? footer;
final VoidCallback? onBack;
/// The promise, set on the brand field itself — "Book a pickup from your
/// door." The app's first screen should say what the app is for in the app's
/// own voice, at display size, before it asks for anything. Only the entry
/// screen carries one; the screens after it have already made the promise.
final String? heroTitle;
/// One supporting line under [heroTitle].
final String? heroSub;
/// Forces the short banner. For screens that raise the keyboard themselves on
/// the first frame — otherwise they render the tall block for one frame and
/// then animate it away, which is a splash screen wearing a different hat.
final bool compactBanner;
/// The CUSTOMER label beside the mark. It belongs on the way in, where a
/// customer might wonder which Doormile app they opened; two screens later
/// they know, and it is just a word taking up the mark's room.
final bool badge;
/// How far the sheet is pulled up over the banner.
static const double _lift = 18;
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: DmColors.canvas,
body: Column(
children: [
Stack(
children: [
// `Scaffold` consumes the bottom view inset, so a child inside
// `body` sees zero whether the keyboard is up or not. This
// context sits above the Scaffold and still carries the real
// value — handing it back lets the banner react to the keyboard
// without disturbing the Scaffold's own resizing.
MediaQuery(
data: MediaQuery.of(context),
child: AuthBrandBanner(
title: heroTitle,
sub: heroSub,
badge: badge,
compact: compactBanner,
back: onBack == null
? null
: _BackButton(onPressed: onBack!),
),
),
],
),
Expanded(
child: Transform.translate(
offset: const Offset(0, -_lift),
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: DmColors.canvas,
borderRadius: BorderRadius.vertical(
top: Radius.circular(DmRadius.xl),
),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Column(
// Stretch, not the default centre: the heading block is a
// Padding sized to its own text, so a centred column was
// pushing "Verify your number" into the middle of the
// sheet while the form under it ranged left.
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── The question is pinned, only the form scrolls ──
//
// Focusing the field made Flutter scroll it into view,
// which carried "Verify your number" off the top of the
// sheet: the screen asked a question and then hid it
// the moment anyone went to answer. The heading is not
// part of the scrollable content any more, so it cannot
// be scrolled away by the keyboard.
if (title != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
26,
DmSpace.pad,
0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title!, style: DmText.title),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(subtitle!, style: DmText.lede),
],
],
),
),
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.fromLTRB(
DmSpace.pad,
title == null ? 26 : 24,
DmSpace.pad,
12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
if (footer != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
4,
DmSpace.pad,
10 + _lift,
),
child: footer!,
),
],
),
),
),
),
),
],
),
),
);
}
}
/// The masthead every auth screen wears.
///
/// ── Left-aligned, and it speaks ──
///
/// This was a centred lockup floating in a fixed 210pt block: correct, and
/// completely silent. A customer opening the app for the first time was shown a
/// logo and then asked for their phone number, with nothing in between saying
/// what they were signing up to.
///
/// The block now reads top-down like the rest of the app — lockup, then the
/// promise at display size, then one supporting line — all ranged left on the
/// same 24pt margin the sheet below uses, so the crimson field and the white
/// sheet share one column rather than looking like two different screens.
///
/// The wordmark is **knocked out, not re-exported**: `doormile-logo.png` is
/// RGBA, so `BlendMode.srcIn` fills every non-transparent pixel white and gives
/// a clean reverse of the red original. Shipping a second white asset would
/// mean two logo files that drift; this one cannot.
class AuthBrandBanner extends StatelessWidget {
const AuthBrandBanner({
super.key,
this.title,
this.sub,
this.back,
this.compact = false,
this.badge = true,
});
/// See [AuthScaffold.heroTitle].
final String? title;
final String? sub;
/// The back affordance, drawn above the lockup rather than floated over it.
final Widget? back;
/// See [AuthScaffold.compactBanner].
final bool compact;
/// See [AuthScaffold.badge].
final bool badge;
/// The source wordmark is 748×100. Held explicitly so a layout change cannot
/// squash the mark.
static const double _markAspect = 748 / 100;
@override
Widget build(BuildContext context) {
// While the keyboard is up the promise gives way: the field the customer is
// typing into matters more than the sentence they have already read. The
// lockup stays, so the screen never loses its identity mid-form.
final typing = compact || MediaQuery.viewInsetsOf(context).bottom > 0;
// Without the badge beside it the mark is the only thing on the field, so
// it is given the room the badge was taking rather than sitting small in a
// corner of an otherwise empty block.
final markWidth = badge ? (typing ? 132.0 : 150.0) : 196.0;
return ColoredBox(
color: DmColors.brand,
child: CustomPaint(
painter: const DmArcField(opacity: 0.16),
child: SafeArea(
bottom: false,
child: AnimatedSize(
duration: DmMotion.base,
curve: DmMotion.ease,
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.fromLTRB(
DmSpace.pad,
back == null ? 14 : 6,
DmSpace.pad,
typing ? 22 : 34,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (back != null) ...[back!, const SizedBox(height: 14)],
Row(
children: [
Flexible(
child: Semantics(
label: 'Doormile',
image: true,
child: SizedBox(
width: markWidth,
height: markWidth / _markAspect,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/doormile-logo.png',
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
filterQuality: FilterQuality.medium,
),
),
),
),
),
// Which Doormile app this is. Outlined rather than
// filled: it is a label on the mark, not a status.
if (badge) ...[
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
decoration: BoxDecoration(
border: Border.all(color: const Color(0x80FFFFFF)),
borderRadius: DmRadius.all(5),
),
child: Text(
'CUSTOMER',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.eyebrow.copyWith(
fontSize: 10,
color: Colors.white,
letterSpacing: 1.4,
),
),
),
],
],
),
if (title != null && !typing) ...[
const SizedBox(height: 26),
// Held to a narrow measure so the headline breaks where the
// design breaks it — two short lines, not one long one that
// runs the full width of a phone.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Text(
title!,
// Display is inked for the canvas; on the brand field
// it knocks out white.
style: DmText.display.copyWith(color: Colors.white),
),
),
if (sub != null) ...[
const SizedBox(height: 10),
Text(
sub!,
style: DmText.lede.copyWith(color: DmColors.onBrand2),
),
],
],
],
),
),
),
),
),
);
}
}
class _BackButton extends StatelessWidget {
const _BackButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Material(
color: DmColors.onBrandRaise,
borderRadius: DmRadius.all(DmRadius.sm),
child: InkWell(
borderRadius: DmRadius.all(DmRadius.sm),
onTap: onPressed,
child: Ink(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.sm),
border: Border.all(color: const Color(0x4DFFFFFF)),
),
child: const Icon(
LucideIcons.arrowLeft,
size: 19,
color: Colors.white,
),
),
),
);
}
}
/// The fine print shared by every auth screen.
class AuthLegal extends StatelessWidget {
const AuthLegal({super.key});
@override
Widget build(BuildContext context) {
return Text(
"By continuing you agree to Doormile's Terms and Privacy Policy.",
textAlign: TextAlign.center,
style: DmText.tiny.copyWith(fontSize: 12, height: 1.55),
);
}
}
/// The cross-link between sign in and sign up.
class AuthSwitchLink extends StatelessWidget {
const AuthSwitchLink({
super.key,
required this.question,
required this.action,
required this.onTap,
});
final String question;
final String action;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
question,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.small.copyWith(fontSize: 13.5),
),
),
InkWell(
onTap: onTap,
borderRadius: DmRadius.all(DmRadius.xs),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Text(
action,
style: DmText.label.copyWith(
fontSize: 13.5,
color: DmColors.brand,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../state/app_scope.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/pieces.dart';
import 'auth_scaffold.dart';
import 'otp_screen.dart';
import 'signup_screen.dart';
enum LoginMode { phone, email }
/// Sign in — phone or email, then a 4-digit code.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _identifier = TextEditingController();
LoginMode _mode = LoginMode.phone;
bool _sending = false;
@override
void initState() {
super.initState();
_identifier.addListener(() => setState(() {}));
}
@override
void dispose() {
_identifier.dispose();
super.dispose();
}
bool get _isPhone => _mode == LoginMode.phone;
bool get _valid => _isPhone
? _identifier.text.trim().length >= 10
: _identifier.text.contains('@');
Future<void> _continue() async {
final raw = _identifier.text.trim();
setState(() => _sending = true);
final target = _isPhone ? '+91 $raw' : raw;
try {
final challenge = await AppScope.read(context).sendOtp(target);
if (!mounted) return;
setState(() => _sending = false);
// ── No code sent, no code screen ──
//
// `sent: false` is the server saying it accepted the request but did not
// deliver anything — the SMS gateway or the mail relay refused it. The
// resend path on the next screen has always honoured this flag; this one
// did not, and walked the customer to four empty boxes for a code that
// was never issued. Everything they can type there is wrong, and the
// failure reads to them as "I entered it wrong" rather than "nothing
// arrived". Stay here, say what happened, let them try again.
if (!challenge.sent) {
DmToast.show(
context,
'We could not send a code to that ${_isPhone ? 'number' : 'address'}. '
'Try again in a moment.',
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
OtpScreen(identifier: target, challenge: challenge),
),
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _sending = false);
DmToast.show(context, e.message);
} catch (_) {
// Anything the API layer did not turn into an ApiException still has to
// clear the spinner. Without this the button stays mid-send forever and
// the screen offers no way forward at all — a worse failure than the one
// being reported.
if (!mounted) return;
setState(() => _sending = false);
DmToast.show(context, 'Something went wrong. Please try again.');
}
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
// The promise sits on the crimson, at display size, and the sheet gets
// on with the form. A "Welcome back" heading in the sheet said nothing
// the screen did not already say, and pushed the first control down.
heroTitle: 'Book a pickup from your door.',
heroSub: 'Track every parcel through to delivery.',
// The CTA is pinned, not scrolled. In the scroll area it was clipped by
// the footer the moment the keyboard came up — the one control the screen
// exists for, hidden exactly when it is needed.
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
DmButton(
label: 'Continue',
busy: _sending,
busyLabel: 'Sending code…',
onPressed: _valid ? _continue : null,
),
const SizedBox(height: 10),
AuthSwitchLink(
question: 'New to Doormile?',
action: 'Create account',
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SignUpScreen()),
),
),
const SizedBox(height: 6),
const AuthLegal(),
],
),
children: [
// The same chip the Orders filter and the address labels use. One
// selection control across the app means the customer learns it once,
// on the screen they meet first.
Row(
children: [
for (final mode in LoginMode.values) ...[
if (mode != LoginMode.values.first) const SizedBox(width: 8),
DmChoiceChip(
label: mode == LoginMode.phone ? 'Phone' : 'Email',
selected: _mode == mode,
expand: true,
onTap: () => setState(() {
_mode = mode;
_identifier.clear();
}),
),
],
],
),
const SizedBox(height: 22),
DmTextField(
label: _isPhone ? 'Phone number' : 'Email address',
controller: _identifier,
hint: _isPhone ? '98765 43210' : 'you@example.com',
prefix: _isPhone ? '+91' : null,
keyboardType: _isPhone ? TextInputType.phone : TextInputType.emailAddress,
maxLength: _isPhone ? 10 : 60,
digitsOnly: _isPhone,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _valid ? _continue() : null,
onClear: _identifier.text.isEmpty ? null : () => _identifier.clear(),
),
// Sits under the field it explains rather than floating below the
// button, where it read as a fourth thing to deal with.
const DmInfoBanner(
icon: LucideIcons.shieldCheck,
title: 'Your number stays private',
message: "We'll send a 4-digit code to keep your parcels secure.",
),
],
);
}
}

View File

@@ -0,0 +1,369 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../shell_screen.dart';
import 'auth_scaffold.dart';
/// Verify the code. Shared by sign in and sign up.
///
/// How many digits and how long until a resend are the **server's** to say —
/// they arrive on [challenge] from the call that asked for the code. Nothing
/// here is a constant that a backend change could make wrong.
class OtpScreen extends StatefulWidget {
const OtpScreen({
super.key,
required this.identifier,
this.name,
this.challenge = OtpChallenge.unspecified,
});
/// The phone number or email the code was sent to.
final String identifier;
/// Set when arriving from sign up, so the new account keeps its name.
final String? name;
/// What the server said about this challenge.
final OtpChallenge challenge;
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
late OtpChallenge _challenge = widget.challenge;
late final _boxes = List.generate(
_challenge.codeLength,
(_) => TextEditingController(),
);
late final _focus = List.generate(
_challenge.codeLength,
(_) => FocusNode(),
);
Timer? _ticker;
late int _resendIn = _challenge.resendAfterSeconds;
bool _verifying = false;
int get _length => _boxes.length;
String get _code => _boxes.map((c) => c.text).join();
bool get _complete => _code.length == _length;
@override
void initState() {
super.initState();
_startResendTimer();
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.first.requestFocus());
}
void _startResendTimer() {
_ticker?.cancel();
setState(() => _resendIn = _challenge.resendAfterSeconds);
if (_resendIn <= 0) return;
_ticker = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) return;
setState(() => _resendIn--);
if (_resendIn <= 0) t.cancel();
});
}
@override
void dispose() {
_ticker?.cancel();
for (final c in _boxes) {
c.dispose();
}
for (final f in _focus) {
f.dispose();
}
super.dispose();
}
Future<void> _resend() async {
late final OtpChallenge challenge;
try {
challenge = await AppScope.read(context).sendOtp(widget.identifier);
} on ApiException catch (e) {
if (!mounted) return;
DmToast.show(context, e.message);
return;
}
if (!mounted) return;
// The box count is fixed for the life of this screen — the controllers are
// already built — but the resend window can move, so only that is adopted.
_challenge = OtpChallenge(
codeLength: _length,
resendAfterSeconds: challenge.resendAfterSeconds,
sent: challenge.sent,
);
_startResendTimer();
DmToast.show(
context,
challenge.sent ? 'Code sent again' : 'We could not send a code',
);
}
Future<void> _verify() async {
setState(() => _verifying = true);
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.verifyOtp(widget.identifier, _code, name: widget.name);
if (!mounted) return;
app.detectPickupLocation();
unawaited(HapticFeedback.mediumImpact());
await navigator.pushAndRemoveUntil(
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
(route) => false,
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _verifying = false);
for (final c in _boxes) {
c.clear();
}
_focus.first.requestFocus();
DmToast.show(context, e.message);
}
}
/// Accepts a pasted code by spreading it across the boxes.
void _onChanged(int index, String value) {
if (value.length > 1) {
final digits = value.replaceAll(RegExp(r'\D'), '');
for (var i = 0; i < _boxes.length; i++) {
_boxes[i].text = i < digits.length ? digits[i] : '';
}
_focus[digits.length.clamp(0, _length - 1)].requestFocus();
} else if (value.isNotEmpty && index < 3) {
_focus[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focus[index - 1].requestFocus();
}
setState(() {});
if (_complete && !_verifying) _verify();
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
compactBanner: true,
// The mark alone, without the CUSTOMER label: by the third screen of the
// entrance nobody is still asking which app this is, and the question on
// this screen is a four-digit code, not a brand.
badge: false,
onBack: () => Navigator.of(context).maybePop(),
title: 'Verify your number',
subtitle: 'Enter the $_length-digit code we just sent.',
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
DmButton(
label: 'Verify and continue',
busy: _verifying,
busyLabel: 'Verifying…',
onPressed: _complete ? _verify : null,
),
const SizedBox(height: 12),
const AuthLegal(),
],
),
children: [
// The number is restated in mono so the digits can be checked one by
// one — this is the screen where a transposed digit means a code that
// never arrives — with the way back one tap away.
DmCard(
tone: DmCardTone.wash,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
child: Row(
children: [
const DmTile(icon: LucideIcons.shieldCheck, tone: DmTileTone.wash),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.identifier,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.monoLg.copyWith(
fontSize: 15.5,
color: DmColors.brandPress,
),
),
),
DmTextAction(
label: 'Change',
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
),
],
),
const SizedBox(height: 22),
// Four equal boxes across the column, which is how the code is drawn
// in the design and how every other code field a customer has used
// behaves. Capped, though: stretched across a tablet each digit would
// sit alone in a 150-wide slab and stop looking like a digit.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Row(
children: [
for (var i = 0; i < _length; i++) ...[
if (i > 0) const SizedBox(width: 11),
Expanded(
child: _OtpBox(
controller: _boxes[i],
focusNode: _focus[i],
onChanged: (v) => _onChanged(i, v),
),
),
],
],
),
),
),
const SizedBox(height: 20),
// The countdown is mono so the seconds tick in place instead of the
// whole line reflowing every second as the glyph widths change.
Row(
children: [
Expanded(
child: _resendIn > 0
? Text.rich(
TextSpan(
style: DmText.tiny,
children: [
const TextSpan(text: 'Resend available in '),
TextSpan(
text: '0:${_resendIn.toString().padLeft(2, '0')}',
style: DmText.monoSm.copyWith(
fontSize: 13,
color: DmColors.brand,
),
),
],
),
)
: Text('Did not get it?', style: DmText.tiny),
),
DmTextAction(
label: 'Resend',
color: _resendIn > 0 ? DmColors.ink4 : DmColors.brand,
onPressed: _resendIn > 0 ? null : _resend,
),
],
),
],
);
}
}
/// One digit of the code.
///
/// Was a filled `TextField` with no border, which read as a disabled field:
/// four grey slabs with nothing to say which one you were typing into. It is
/// the only thing on the screen, so it carries the state instead — empty is
/// outlined, focused takes the brand edge and lifts, filled tints. The
/// `TextField` inside is bare; the container owns every visual.
class _OtpBox extends StatefulWidget {
const _OtpBox({
required this.controller,
required this.focusNode,
required this.onChanged,
});
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
@override
State<_OtpBox> createState() => _OtpBoxState();
}
class _OtpBoxState extends State<_OtpBox> {
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocus);
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocus);
super.dispose();
}
void _onFocus() => setState(() {});
@override
Widget build(BuildContext context) {
final focused = widget.focusNode.hasFocus;
final filled = widget.controller.text.isNotEmpty;
return AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
height: 60,
decoration: BoxDecoration(
color: filled && !focused ? DmColors.brandSoft : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(
color: focused
? DmColors.brand
: filled
? DmColors.brandLine
: DmColors.border,
),
// The focus ring, exactly as the fields draw it — this is the same
// control, one digit wide.
boxShadow: focused
? const [
BoxShadow(
color: DmColors.brandSoft,
spreadRadius: 3,
blurRadius: 0,
),
]
: null,
),
child: Center(
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
onChanged: widget.onChanged,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
cursorColor: DmColors.brand,
cursorWidth: 2,
showCursor: true,
style: DmText.otp,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
counterText: '',
isDense: true,
filled: false,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../state/app_scope.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/pieces.dart';
import 'auth_scaffold.dart';
import 'otp_screen.dart';
/// Create an account — name and phone, with email optional.
///
/// The same OTP screen verifies both sign in and sign up; the name is carried
/// through so the new account is created with it.
class SignUpScreen extends StatefulWidget {
const SignUpScreen({super.key});
@override
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends State<SignUpScreen> {
final _name = TextEditingController();
final _phone = TextEditingController();
final _email = TextEditingController();
bool _sending = false;
@override
void initState() {
super.initState();
for (final c in [_name, _phone, _email]) {
c.addListener(() => setState(() {}));
}
}
@override
void dispose() {
for (final c in [_name, _phone, _email]) {
c.dispose();
}
super.dispose();
}
bool get _valid =>
_name.text.trim().length >= 2 && _phone.text.trim().length >= 10;
Future<void> _continue() async {
setState(() => _sending = true);
final phone = '+91 ${_phone.text.trim()}';
try {
final challenge = await AppScope.read(context).signUp(
name: _name.text.trim(),
phone: phone,
email: _email.text.trim().isEmpty ? null : _email.text.trim(),
);
if (!mounted) return;
setState(() => _sending = false);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => OtpScreen(
identifier: phone,
name: _name.text.trim(),
challenge: challenge,
),
),
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _sending = false);
DmToast.show(context, e.message);
}
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
onBack: () => Navigator.of(context).maybePop(),
// Same entrance as sign in: the headline sits on the brand field at
// display size and the sheet starts straight into the form. Sign in and
// sign up are one doorway with two doors, so they cannot be laid out
// differently — a customer who bounces between them should see the
// screen change its words, not its shape.
heroTitle: 'Create your account.',
heroSub: 'A few details and you can book your first pickup.',
// Pinned for the same reason as sign in: a three-field form plus the
// keyboard leaves no room, and the CTA must not be the thing that loses.
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
DmButton(
label: 'Continue',
busy: _sending,
busyLabel: 'Sending code…',
onPressed: _valid ? _continue : null,
),
const SizedBox(height: 10),
AuthSwitchLink(
question: 'Already have an account?',
action: 'Sign in',
onTap: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 6),
const AuthLegal(),
],
),
children: [
DmTextField(
label: 'Full name',
controller: _name,
hint: 'Joe Oommen',
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Phone number',
controller: _phone,
hint: '98765 43210',
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
maxLength: 10,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Email',
optional: true,
controller: _email,
hint: 'you@example.com',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _valid ? _continue() : null,
),
// The same reassurance sign in carries, in the same wash card, under
// the field it is about.
const DmInfoBanner(
icon: LucideIcons.shieldCheck,
title: 'We verify every number',
message: "A 4-digit code confirms it's you and keeps your parcels "
'secure.',
),
],
);
}
}