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:
440
lib/ui/screens/auth/auth_scaffold.dart
Normal file
440
lib/ui/screens/auth/auth_scaffold.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/ui/screens/auth/login_screen.dart
Normal file
170
lib/ui/screens/auth/login_screen.dart
Normal 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.",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
369
lib/ui/screens/auth/otp_screen.dart
Normal file
369
lib/ui/screens/auth/otp_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/ui/screens/auth/signup_screen.dart
Normal file
146
lib/ui/screens/auth/signup_screen.dart
Normal 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.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user