mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
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.
This commit is contained in:
@@ -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<CustomProxyHeader> initialHeaders;
|
||||
@@ -13,7 +14,8 @@ class CustomProxyHeadersEditor extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomProxyHeadersEditor> createState() => _CustomProxyHeadersEditorState();
|
||||
State<CustomProxyHeadersEditor> createState() =>
|
||||
_CustomProxyHeadersEditorState();
|
||||
}
|
||||
|
||||
class _CustomProxyHeadersEditorState extends State<CustomProxyHeadersEditor> {
|
||||
@@ -30,7 +32,12 @@ class _CustomProxyHeadersEditorState extends State<CustomProxyHeadersEditor> {
|
||||
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();
|
||||
|
||||
190
mobile/lib/widgets/sure_text_field.dart
Normal file
190
mobile/lib/widgets/sure_text_field.dart
Normal file
@@ -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<TextInputFormatter>? inputFormatters;
|
||||
|
||||
final int maxLines;
|
||||
final int? minLines;
|
||||
final int? maxLength;
|
||||
|
||||
final FormFieldValidator<String>? validator;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
49
mobile/test/widgets/custom_proxy_headers_editor_test.dart
Normal file
49
mobile/test/widgets/custom_proxy_headers_editor_test.dart
Normal file
@@ -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<void> 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<CustomProxyHeader>? 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');
|
||||
});
|
||||
}
|
||||
140
mobile/test/widgets/sure_text_field_test.dart
Normal file
140
mobile/test/widgets/sure_text_field_test.dart
Normal file
@@ -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<void> 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<TextField>(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<Text>(find.text('Email')).style?.color,
|
||||
SureTokens.light.textSecondary);
|
||||
|
||||
await pump(tester, const SureTextField(label: 'Email', enabled: false));
|
||||
expect(tester.widget<Text>(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<TextField>(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<TextField>(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<FormState>();
|
||||
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<Text>(find.text('Required'));
|
||||
expect(error.style?.color, SureTokens.light.destructive);
|
||||
});
|
||||
}
|
||||
|
||||
String? _required(String? v) => (v == null || v.isEmpty) ? 'Required' : null;
|
||||
Reference in New Issue
Block a user