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.obscure = 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; /// Masks what is typed. For a PIN, which is a credential a customer may be /// entering with somebody stood next to them — and which, unlike a posted /// OTP, they will keep using. final bool obscure; final TextInputType? keyboardType; final int? maxLength; final int maxLines; final String? prefix; final TextInputAction? textInputAction; final ValueChanged? 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 createState() => _DmTextFieldState(); } class _DmTextFieldState extends State { 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, obscureText: widget.obscure, // Masked fields default to a dot the mono face does not // carry, which renders as a box. A bullet is in every // family the app bundles. obscuringCharacter: '•', 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 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 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? 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), ); }