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, }); /// 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? 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; @override State createState() => _DmTextFieldState(); } class _DmTextFieldState extends State { final FocusNode _node = FocusNode(); @override void initState() { super.initState(); _node.addListener(() => setState(() {})); } @override void dispose() { _node.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final focused = _node.hasFocus; 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) ...[ 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, ), ), ], ), ), const SizedBox(height: 8), ], 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, hintText: 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, }); final String label; final String? value; final Widget? trailing; final VoidCallback? onTap; final bool showChevron; /// The glyph in the row's leading tile. Account's rows all carry one, so the /// list can be scanned by shape before it is read. final IconData? icon; @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: 56), padding: const EdgeInsets.fromLTRB(16, 11, 16, 11), child: Row( children: [ if (icon != null) ...[ DmTile(icon: icon, tone: DmTileTone.wash), const SizedBox(width: 12), ], // The label is what the row IS, so it never wraps and never // gives up width; the value is context and ellipsizes. A saved // address as a value would otherwise push "Saved addresses" onto // two lines. Expanded( flex: 3, child: Text( label, style: DmText.cardTitle, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (value != null) ...[ const SizedBox(width: 10), Flexible( flex: 2, child: Text( value!, style: DmText.value, textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ?trailing, if (showChevron) ...[ const SizedBox(width: 8), const Icon( LucideIcons.chevronRight, size: 18, color: DmColors.ink4, ), ], ], ), ), ), ); } } /// Card that rules a group of [DmRow]s together. 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), ); } }