Files
doormile_customer_app/lib/ui/widgets/inputs.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

478 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'cards.dart';
/// Labelled field.
///
/// White with a hairline at rest, and on focus the edge goes crimson with a
/// 3pt wash ring around it. The ring is the whole reason this is a container
/// rather than an `InputDecoration`: it is a spread shadow in the brand's
/// lightest tint, which no `InputBorder` can draw, and it is what makes the
/// focused field read as lit rather than merely outlined.
///
/// [optional] appends the grey marker that keeps skippable destination and
/// recipient fields honestly signposted.
class DmTextField extends StatefulWidget {
const DmTextField({
super.key,
this.label,
required this.controller,
this.hint,
this.optional = false,
this.keyboardType,
this.maxLength,
this.maxLines = 1,
this.prefix,
this.textInputAction,
this.onSubmitted,
this.autofocus = false,
this.digitsOnly = false,
this.mono = false,
this.onClear,
this.floating = false,
});
/// The caption above the field, or null for a field that stands alone.
///
/// Nullable because a screen whose heading already says what to type does
/// not need to say it again directly underneath — "Enter your mobile number"
/// above a field captioned "Phone number" is one sentence printed twice.
final String? label;
final TextEditingController controller;
final String? hint;
final bool optional;
final TextInputType? keyboardType;
final int? maxLength;
final int maxLines;
final String? prefix;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
final bool autofocus;
final bool digitsOnly;
/// Sets the value in mono — a pincode or a phone number, where the customer
/// checks the digits back.
final bool mono;
/// Shows a clear button on the right when non-null.
final VoidCallback? onClear;
/// Puts [label] inside the field while it is empty, and floats it above once
/// there is something to caption.
///
/// An empty field under its own caption is two rows to ask one question, and
/// on a screen with two of them that is four rows of chrome around two
/// slabs of white. Floating, the question sits where the answer will go — so
/// the field is never blank and the caption only appears when it has a job,
/// which is reminding the customer what they already typed.
final bool floating;
@override
State<DmTextField> createState() => _DmTextFieldState();
}
class _DmTextFieldState extends State<DmTextField> {
final FocusNode _node = FocusNode();
@override
void initState() {
super.initState();
_node.addListener(() => setState(() {}));
if (widget.floating) {
widget.controller.addListener(_onText);
}
}
void _onText() => setState(() {});
@override
void dispose() {
if (widget.floating) widget.controller.removeListener(_onText);
_node.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final focused = _node.hasFocus;
// A floating label is above the field only once it has a reason to be:
// something typed, or a cursor in it.
final lifted = !widget.floating ||
focused ||
widget.controller.text.isNotEmpty;
final textStyle = widget.mono
? DmText.monoLg.copyWith(fontSize: 15.5, color: DmColors.ink)
: DmText.field;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.label != null)
AnimatedSize(
duration: DmMotion.fast,
curve: DmMotion.ease,
alignment: Alignment.bottomLeft,
child: !lifted
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
constraints: BoxConstraints(
minHeight: widget.maxLines > 1 ? 0 : DmSpace.field,
),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.sm),
border: Border.all(
color: focused ? DmColors.brand : DmColors.border,
),
boxShadow: focused
? const [
BoxShadow(
color: DmColors.brandSoft,
spreadRadius: 3,
blurRadius: 0,
),
]
: null,
),
child: Row(
crossAxisAlignment: widget.maxLines > 1
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
if (widget.prefix != null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(15, 0, 12, 0),
child: Text(
widget.prefix!,
style: DmText.field.copyWith(
color: DmColors.ink2,
fontWeight: FontWeight.w700,
),
),
),
// A hairline between the country code and the number: they
// are two different things and reading them as one string is
// how a digit gets lost.
Container(width: 1, height: 22, color: DmColors.border),
],
Expanded(
child: TextField(
controller: widget.controller,
focusNode: _node,
keyboardType: widget.keyboardType,
maxLines: widget.maxLines,
autofocus: widget.autofocus,
textInputAction: widget.textInputAction,
onSubmitted: widget.onSubmitted,
cursorColor: DmColors.brand,
cursorRadius: const Radius.circular(2),
style: textStyle,
inputFormatters: [
if (widget.maxLength != null)
LengthLimitingTextInputFormatter(widget.maxLength),
if (widget.digitsOnly)
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
isDense: true,
// While the label is down inside the field it IS the
// placeholder; [hint] only gets the slot once the label
// has floated out of it.
hintText: lifted
? widget.hint
: (widget.label ?? widget.hint),
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
filled: false,
counterText: '',
contentPadding: EdgeInsets.fromLTRB(
widget.prefix == null ? 15 : 12,
widget.maxLines > 1 ? 15 : 14,
8,
widget.maxLines > 1 ? 15 : 14,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),
if (widget.onClear != null)
Padding(
padding: const EdgeInsets.only(right: 6),
child: InkWell(
onTap: widget.onClear,
borderRadius: DmRadius.all(DmRadius.xs),
child: const SizedBox(
width: 34,
height: 34,
child: Icon(LucideIcons.x, size: 17, color: DmColors.ink3),
),
),
),
],
),
),
],
),
);
}
}
/// A settings row: label left, value or control right.
class DmRow extends StatelessWidget {
const DmRow({
super.key,
required this.label,
this.value,
this.trailing,
this.onTap,
this.showChevron = false,
this.icon,
this.note,
});
final String label;
final String? value;
final Widget? trailing;
final VoidCallback? onTap;
final bool showChevron;
/// The row's leading glyph. Account's rows all carry one, so the list can be
/// scanned by shape before it is read.
///
/// It used to sit in a washed crimson tile. Four of those down a settings
/// list put four brand-coloured blocks on the quietest screen in the app,
/// and each one cost 56pt of the width the row's value needed — which is why
/// "38 Mettupalayam Road" rendered as "38 Mettupal…". The glyph alone scans
/// just as well.
final IconData? icon;
/// The line under the label — "3 locations configured", "Real-time SMS &
/// Push". It is what turns a menu into a list of states the customer can
/// check without opening anything, which is most of what a settings screen
/// is for.
final String? note;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
child: Container(
constraints: const BoxConstraints(minHeight: 58),
padding: const EdgeInsets.fromLTRB(18, 13, 18, 13),
child: Row(
children: [
if (icon != null) ...[
// A glyph in a well, not a glyph. Six bare strokes down a
// settings list read as a column of marks; six wells read as
// six rows, and the well is what gives the label a left edge
// to sit against.
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(icon, size: 17, color: DmColors.ink2),
),
const SizedBox(width: 13),
],
// The label is what the row IS and it is never the thing that
// truncates. Two rounds of flex arithmetic here produced
// "Payment metho…" and "38 Mettupal…" in turn; the fix was not a
// third ratio, it was to stop passing values that cannot fit.
// A [value] is a word — "UPI", "SMS and push" — and anything
// longer belongs on the screen the row opens.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: DmText.cardTitle.copyWith(fontSize: 15.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (value != null) ...[
const SizedBox(width: 12),
Text(
value!,
style: DmText.value,
textAlign: TextAlign.right,
maxLines: 1,
),
],
?trailing,
if (showChevron) ...[
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.ink4,
),
],
],
),
),
),
);
}
}
/// A run of [DmRow]s on one surface.
///
/// ── Card → rules → card ──
///
/// It was a bordered card, then bare ruled rows on the canvas. The bare rows
/// were right about the border — three decorations were doing one job — and
/// wrong about the surface: with nothing under them Account read as a text
/// file, while every other list in the app had come back onto a card.
///
/// It is a [DmCard] again, which now separates by lift rather than by edge.
/// The rules stay, because they belong to the card and are what let one
/// [DmRow] work alone or in a stack of five.
class DmRowGroup extends StatelessWidget {
const DmRowGroup({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => DmCard(children: children);
}
/// Switch in brand colours.
class DmSwitch extends StatelessWidget {
const DmSwitch({super.key, required this.value, required this.onChanged});
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Switch.adaptive(
value: value,
onChanged: (v) {
HapticFeedback.selectionClick();
onChanged(v);
},
activeThumbColor: Colors.white,
activeTrackColor: DmColors.brand,
inactiveThumbColor: Colors.white,
inactiveTrackColor: DmColors.border,
trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
);
}
}
/// The search field used by every picker sheet.
///
/// It was written inline inside the pickup sheet and then wanted again by the
/// destination sheet, which is the moment a style becomes a component. Filled
/// white on the canvas rather than outlined: a search field that is the only
/// control above a list does not need an edge to be found, and the fill is what
/// makes it read as a place to type rather than as the first row of the list.
class DmSearchField extends StatelessWidget {
const DmSearchField({
super.key,
required this.controller,
required this.hint,
this.onChanged,
this.autofocus = false,
});
final TextEditingController controller;
final String hint;
final ValueChanged<String>? onChanged;
final bool autofocus;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
onChanged: onChanged,
autofocus: autofocus,
cursorColor: DmColors.brand,
style: DmText.field,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: hint,
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
prefixIcon: const Icon(
LucideIcons.search,
size: 20,
color: DmColors.ink3,
),
filled: true,
fillColor: DmColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: _border(DmColors.border, 1),
enabledBorder: _border(DmColors.border, 1),
focusedBorder: _border(DmColors.brand, 1.5),
),
);
}
static OutlineInputBorder _border(Color color, double width) =>
OutlineInputBorder(
borderRadius: DmRadius.all(DmRadius.sm),
borderSide: BorderSide(color: color, width: width),
);
}