Files
sure/mobile/lib/widgets/sure_text_field.dart
ghost 3a7dc3c346 design-system(mobile): SureSpacing + SureTypography scale tokens (#2438)
* feat(mobile): add SureSpacing + SureTypography scale tokens

Introduce hand-authored spacing and type-scale constants mirroring the
Tailwind defaults the web design system relies on, so widgets reference a
named step instead of a raw numeric EdgeInsets/SizedBox/fontSize.

- SureSpacing: xs..huge mapping to Tailwind space-1..space-8 (4..32px).
- SureTypography: xs..xxl mapping to Tailwind text-xs..text-2xl font sizes.

Both are hand-written rather than generated from sure.tokens.json because
spacing and the type ramp come from Tailwind's built-in scale, not the
canonical token file (consistent with the tracker's guidance).

Adopt them in the existing primitives (card padding, button metrics +
gap, chip/segmented/list-group gaps and padding, text-field padding +
label gap). All migrations are value-preserving — each token equals the
literal it replaces — so there is no layout change; off-scale one-offs
(control heights, hairlines, deliberate 14px field padding) stay literal.

flutter analyze: no new issues; full suite (166) green.

* docs(mobile): note off-scale SureChip inset; expose SureTypography line heights

Address review feedback (jjmata):

- SureChip: add an inline comment explaining the horizontal:14 content inset is
  deliberately off the SureSpacing scale (between lg=12 and xl=16) — a tuned
  FilterChip-parity dimension, not a spacing step — so it isn't "fixed" to a
  token later.
- SureTypography: expose the paired line heights (xsLineHeight … xxlLineHeight,
  logical px matching the Tailwind text-* defaults) that were previously only in
  doc comments, with a note on deriving Flutter's TextStyle.height multiplier
  (lineHeight / fontSize). No behavior change.
2026-06-30 07:23:24 +02:00

192 lines
6.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_spacing.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: SureSpacing.xl,
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: SureSpacing.sm),
child: Text(
label!,
style: theme.textTheme.labelMedium?.copyWith(
color: enabled ? palette.textSecondary : palette.textSubdued,
fontWeight: FontWeight.w500,
),
),
),
),
Semantics(label: label, child: field),
],
);
}
}