garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
1621 lines
56 KiB
Dart
1621 lines
56 KiB
Dart
// GarageUI text field family. Hand rolled replacement for shadcn's TextField +
|
|
// the InputFeature system (spinner / stepper buttons / clear / leading / trailing).
|
|
//
|
|
// The widget STRUCTURE here is ours, but we still read the theme through shadcn
|
|
// (Theme.of / colorScheme / density / typography) and reuse a couple of sibling
|
|
// family primitives (IconButton, FocusOutline, ButtonGroup) so the pixels line
|
|
// up exactly with the old look. Those prefixed imports get repointed later once
|
|
// the button/layout families land.
|
|
|
|
import "dart:ui" show BoxHeightStyle, BoxWidthStyle;
|
|
|
|
import "package:flutter/widgets.dart";
|
|
import "package:flutter/services.dart";
|
|
import "package:flutter/gestures.dart";
|
|
import "package:flutter/cupertino.dart"
|
|
show cupertinoDesktopTextSelectionHandleControls;
|
|
|
|
import "package:garage_ui/platform/cursor_lock.dart";
|
|
import "package:garage_ui/field_error.dart";
|
|
import "package:garage_ui/semantics_scope.dart";
|
|
import "package:garage_ui/theme/garage_theme.dart";
|
|
import "package:flutter_lucide/flutter_lucide.dart";
|
|
import "package:garage_ui/button.dart";
|
|
|
|
/// Where an input feature sits relative to the editable text.
|
|
enum InputFeaturePosition { leading, trailing, above, below }
|
|
|
|
/// Visual treatment for the field surface.
|
|
enum TextFieldVariant { outline, secondary }
|
|
|
|
// Applies the optical nudge, or nothing at all when the density has it at 0 —
|
|
// no point hanging a transform layer off every control to move it by zero.
|
|
Widget _optionallyNudged(double dy, Widget child) {
|
|
if (dy == 0) return child;
|
|
return Transform.translate(offset: Offset(0, dy), child: child);
|
|
}
|
|
|
|
/// Base class for the little extras that live inside / beside a [TextField] —
|
|
/// stepper buttons, a clear button, custom leading/trailing widgets, etc.
|
|
///
|
|
/// Only the factories the app actually leans on are wired up. Each feature just
|
|
/// yields the widgets it wants in one of the four slots; the field lays them out.
|
|
abstract class InputFeature {
|
|
const InputFeature();
|
|
|
|
/// Increment (+) button for numeric inputs. Renders as an outline icon button
|
|
/// joined onto the field via the surrounding button group.
|
|
const factory InputFeature.incrementButton({
|
|
double step,
|
|
double? invalidValue,
|
|
double? min,
|
|
double? max,
|
|
InputFeaturePosition position,
|
|
Widget? icon,
|
|
}) = _StepperFeature.increment;
|
|
|
|
/// Decrement (-) button. Same as increment but steps the other way.
|
|
const factory InputFeature.decrementButton({
|
|
double step,
|
|
double? invalidValue,
|
|
double? min,
|
|
double? max,
|
|
InputFeaturePosition position,
|
|
Widget? icon,
|
|
}) = _StepperFeature.decrement;
|
|
|
|
/// Clear button (an x). Shows only while there is text.
|
|
const factory InputFeature.clear({
|
|
InputFeaturePosition position,
|
|
Widget? icon,
|
|
}) = _ClearFeature;
|
|
|
|
/// Up/down chevron spinner stacked on the trailing edge, inside the field.
|
|
const factory InputFeature.spinner({
|
|
double step,
|
|
bool enableGesture,
|
|
double? invalidValue,
|
|
double? min,
|
|
double? max,
|
|
}) = _SpinnerFeature;
|
|
|
|
/// A custom widget dropped on the leading side, inside the field.
|
|
const factory InputFeature.leading(Widget child) = _LeadingFeature;
|
|
|
|
/// A custom widget dropped on the trailing side, inside the field.
|
|
const factory InputFeature.trailing(Widget child) = _TrailingFeature;
|
|
|
|
/// Fills the field horizontally with [color] (the theme's primary colour
|
|
/// by default) based on where the current parsed value sits between [min]
|
|
/// and [max]. Sits behind the text, inside the fields own clipped border -
|
|
/// same idea as the scrubbable numeric fields in the colour picker, just
|
|
/// as a background instead of a number you drag.
|
|
const factory InputFeature.fillIndicator({
|
|
required double min,
|
|
required double max,
|
|
double invalidValue,
|
|
Color? color,
|
|
}) = _FillIndicatorFeature;
|
|
|
|
/// Blender-style numeric drag-to-scrub. While unfocused, the field renders
|
|
/// as a plain value display behind a pan gesture instead of the real
|
|
/// editable text - horizontal drag adjusts the (parsed-as-double) value
|
|
/// directly, hold shift for a finer 0.1x step. A drag under the click
|
|
/// threshold focuses the field instead, for normal typing. Only makes
|
|
/// sense on fields holding a plain number. Adds its own trailing
|
|
/// chevronsUpDown icon unless [icon] overrides it.
|
|
const factory InputFeature.scrub({
|
|
double sensitivity,
|
|
int decimals,
|
|
TextAlign? textAlign,
|
|
Widget? icon,
|
|
VoidCallback? onStart,
|
|
ValueChanged<String>? onUpdate,
|
|
VoidCallback? onEnd,
|
|
}) = _ScrubFeature;
|
|
|
|
// slots. prefix/suffix live OUTSIDE the decorated box (button group members),
|
|
// leading/trailing live INSIDE the box next to the text, background sits
|
|
// behind everything else inside the clipped border.
|
|
Iterable<Widget> buildPrefix(TextFieldState state) sync* {}
|
|
Iterable<Widget> buildSuffix(TextFieldState state) sync* {}
|
|
Iterable<Widget> buildLeading(TextFieldState state) sync* {}
|
|
Iterable<Widget> buildTrailing(TextFieldState state) sync* {}
|
|
Iterable<Widget> buildBackground(TextFieldState state) sync* {}
|
|
}
|
|
|
|
// ---- stepper (+/-) -------------------------------------------------------
|
|
|
|
class _StepperFeature extends InputFeature {
|
|
final double step;
|
|
final double? invalidValue;
|
|
final double? min;
|
|
final double? max;
|
|
final InputFeaturePosition position;
|
|
final Widget? icon;
|
|
|
|
const _StepperFeature.increment({
|
|
this.step = 1.0,
|
|
this.invalidValue = 0.0,
|
|
this.min,
|
|
this.max,
|
|
this.position = InputFeaturePosition.trailing,
|
|
this.icon = const Icon(LucideIcons.plus),
|
|
});
|
|
|
|
const _StepperFeature.decrement({
|
|
this.step = -1.0,
|
|
this.invalidValue = 0.0,
|
|
this.min,
|
|
this.max,
|
|
this.position = InputFeaturePosition.trailing,
|
|
this.icon = const Icon(LucideIcons.minus),
|
|
});
|
|
|
|
Widget _button(TextFieldState state) {
|
|
return _StepperButton(
|
|
controller: state.effectiveController,
|
|
step: step,
|
|
min: min,
|
|
max: max,
|
|
invalidValue: invalidValue,
|
|
icon: icon ?? const Icon(LucideIcons.plus),
|
|
onChanged: state.widget.onChanged,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Iterable<Widget> buildSuffix(TextFieldState state) sync* {
|
|
if (position == InputFeaturePosition.trailing) yield _button(state);
|
|
}
|
|
|
|
@override
|
|
Iterable<Widget> buildPrefix(TextFieldState state) sync* {
|
|
if (position == InputFeaturePosition.leading) yield _button(state);
|
|
}
|
|
}
|
|
|
|
class _StepperButton extends StatelessWidget {
|
|
final TextEditingController controller;
|
|
final double step;
|
|
final double? min;
|
|
final double? max;
|
|
final double? invalidValue;
|
|
final Widget icon;
|
|
final ValueChanged<String>? onChanged;
|
|
|
|
const _StepperButton({
|
|
required this.controller,
|
|
required this.step,
|
|
required this.min,
|
|
required this.max,
|
|
required this.invalidValue,
|
|
required this.icon,
|
|
required this.onChanged,
|
|
});
|
|
|
|
double _clamp(double value) {
|
|
if (min != null && value < min!) return min!;
|
|
if (max != null && value > max!) return max!;
|
|
return value;
|
|
}
|
|
|
|
double? _effectiveValue() {
|
|
final value = double.tryParse(controller.text);
|
|
return value ?? invalidValue;
|
|
}
|
|
|
|
bool _canApplyStep(double? value) {
|
|
if (value == null) return false;
|
|
if (step >= 0) {
|
|
return max == null || value < max!;
|
|
}
|
|
return min == null || value > min!;
|
|
}
|
|
|
|
// 2 -> "2", 2.5 -> "2.5", 2.0 -> "2". strip trailing zeros like shadcn does
|
|
String _newText(double value) {
|
|
var newText = value.toString();
|
|
if (newText.contains(".")) {
|
|
while (newText.endsWith("0")) {
|
|
newText = newText.substring(0, newText.length - 1);
|
|
}
|
|
if (newText.endsWith(".")) {
|
|
newText = newText.substring(0, newText.length - 1);
|
|
}
|
|
}
|
|
return newText;
|
|
}
|
|
|
|
void _apply() {
|
|
final text = controller.text;
|
|
final value = double.tryParse(text);
|
|
String newText;
|
|
if (value == null) {
|
|
if (invalidValue == null) return;
|
|
newText = _newText(_clamp(invalidValue!));
|
|
} else {
|
|
newText = _newText(_clamp(value + step));
|
|
}
|
|
if (newText != text) {
|
|
controller.text = newText;
|
|
onChanged?.call(newText);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: controller,
|
|
builder: (context, value, child) {
|
|
final current = _effectiveValue();
|
|
final clamped = current == null ? null : _clamp(current);
|
|
final canStep = _canApplyStep(clamped);
|
|
return AspectRatio(
|
|
aspectRatio: 1,
|
|
child: IconButton.outline(
|
|
icon: icon,
|
|
onPressed: canStep ? _apply : null,
|
|
density: ControlDensity.compact,
|
|
size: ButtonSize.small,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---- clear (x) -----------------------------------------------------------
|
|
|
|
class _ClearFeature extends InputFeature {
|
|
final InputFeaturePosition position;
|
|
final Widget? icon;
|
|
|
|
const _ClearFeature({
|
|
this.position = InputFeaturePosition.trailing,
|
|
this.icon,
|
|
});
|
|
|
|
Widget _button(TextFieldState state) {
|
|
final controller = state.effectiveController;
|
|
return ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: controller,
|
|
builder: (context, value, _) {
|
|
if (value.text.isEmpty) return const SizedBox.shrink();
|
|
return IconButton.ghost(
|
|
icon: icon ?? const Icon(LucideIcons.x),
|
|
density: ControlDensity.compact,
|
|
onPressed: () {
|
|
controller.text = "";
|
|
state.widget.onChanged?.call("");
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
|
|
if (position == InputFeaturePosition.trailing) yield _button(state);
|
|
}
|
|
|
|
@override
|
|
Iterable<Widget> buildLeading(TextFieldState state) sync* {
|
|
if (position == InputFeaturePosition.leading) yield _button(state);
|
|
}
|
|
}
|
|
|
|
// ---- spinner (stacked chevrons) -----------------------------------------
|
|
|
|
class _SpinnerFeature extends InputFeature {
|
|
final double step;
|
|
final bool enableGesture;
|
|
final double? invalidValue;
|
|
final double? min;
|
|
final double? max;
|
|
|
|
const _SpinnerFeature({
|
|
this.step = 1.0,
|
|
this.enableGesture = true,
|
|
this.invalidValue = 0.0,
|
|
this.min,
|
|
this.max,
|
|
});
|
|
|
|
@override
|
|
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
|
|
yield _Spinner(
|
|
controller: state.effectiveController,
|
|
step: step,
|
|
enableGesture: enableGesture,
|
|
min: min,
|
|
max: max,
|
|
invalidValue: invalidValue,
|
|
onChanged: state.widget.onChanged,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Spinner extends StatelessWidget {
|
|
final TextEditingController controller;
|
|
final double step;
|
|
final bool enableGesture;
|
|
final double? min;
|
|
final double? max;
|
|
final double? invalidValue;
|
|
final ValueChanged<String>? onChanged;
|
|
|
|
const _Spinner({
|
|
required this.controller,
|
|
required this.step,
|
|
required this.enableGesture,
|
|
required this.min,
|
|
required this.max,
|
|
required this.invalidValue,
|
|
required this.onChanged,
|
|
});
|
|
|
|
double _clamp(double value) {
|
|
if (min != null && value < min!) return min!;
|
|
if (max != null && value > max!) return max!;
|
|
return value;
|
|
}
|
|
|
|
double? _effectiveValue() => double.tryParse(controller.text) ?? invalidValue;
|
|
|
|
bool _canIncrease(double? v) => v != null && (max == null || v < max!);
|
|
bool _canDecrease(double? v) => v != null && (min == null || v > min!);
|
|
|
|
String _newText(double value) {
|
|
var newText = value.toString();
|
|
if (newText.contains(".")) {
|
|
while (newText.endsWith("0")) {
|
|
newText = newText.substring(0, newText.length - 1);
|
|
}
|
|
if (newText.endsWith(".")) {
|
|
newText = newText.substring(0, newText.length - 1);
|
|
}
|
|
}
|
|
return newText;
|
|
}
|
|
|
|
void _step(double delta) {
|
|
final text = controller.text;
|
|
final value = double.tryParse(text);
|
|
String newText;
|
|
if (value == null) {
|
|
if (invalidValue == null) return;
|
|
newText = _newText(_clamp(invalidValue!));
|
|
} else {
|
|
newText = _newText(_clamp(value + delta));
|
|
}
|
|
if (newText != text) {
|
|
controller.text = newText;
|
|
onChanged?.call(newText);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = GarageTheme.of(context);
|
|
Widget buttons = ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: controller,
|
|
builder: (context, value, child) {
|
|
final current = _effectiveValue();
|
|
final clamped = current == null ? null : _clamp(current);
|
|
// Two half-height arrows sharing the field's interior. Expanded, not
|
|
// natural size: a compact xSmall button is ~11.5 tall, so the pair
|
|
// wanted 23 in a box thats only controlHeight-minus-border (21) and
|
|
// overflowed the bottom by 2. Splitting the space they actually get
|
|
// means the spinner fits whatever interior the density hands it.
|
|
//
|
|
// The icon is laid out at half the row too - it's drawn through a 1.5x
|
|
// Transform.scale below, which costs nothing in layout, so shrinking
|
|
// the layout box doesn't shrink the chevron you see.
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final half = constraints.maxHeight.isFinite
|
|
? constraints.maxHeight / 2
|
|
: theme.density.lineBox;
|
|
final iconSize = (half - 2 * theme.scaling).clamp(6.0, half);
|
|
|
|
return IconTheme.merge(
|
|
data: IconThemeData(size: iconSize),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: [
|
|
Expanded(
|
|
child: IconButton.ghost(
|
|
icon: Transform.translate(
|
|
offset: Offset(0, -1 * theme.scaling),
|
|
child: Transform.scale(
|
|
alignment: Alignment.center,
|
|
scale: 1.5,
|
|
child: const Icon(LucideIcons.chevron_up),
|
|
),
|
|
),
|
|
onPressed: _canIncrease(clamped)
|
|
? () => _step(step)
|
|
: null,
|
|
density: ControlDensity.compact,
|
|
size: ButtonSize.xSmall,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: IconButton.ghost(
|
|
icon: Transform.translate(
|
|
offset: Offset(0, 1 * theme.scaling),
|
|
child: Transform.scale(
|
|
alignment: Alignment.center,
|
|
scale: 1.5,
|
|
child: const Icon(LucideIcons.chevron_down),
|
|
),
|
|
),
|
|
onPressed: _canDecrease(clamped)
|
|
? () => _step(-step)
|
|
: null,
|
|
density: ControlDensity.compact,
|
|
size: ButtonSize.xSmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (enableGesture) {
|
|
buttons = GestureDetector(
|
|
// scrub drag surface, not a control - keep it out of the
|
|
// semantics tree so it doesn't read as a bogus scrollable
|
|
excludeFromSemantics: true,
|
|
onVerticalDragUpdate: (details) {
|
|
if (details.delta.dy < 0) {
|
|
_step(step);
|
|
} else {
|
|
_step(-step);
|
|
}
|
|
},
|
|
child: buttons,
|
|
);
|
|
}
|
|
return buttons;
|
|
}
|
|
}
|
|
|
|
// ---- fill indicator --------------------------------------------------
|
|
|
|
class _FillIndicatorFeature extends InputFeature {
|
|
final double min;
|
|
final double max;
|
|
final double invalidValue;
|
|
final Color? color;
|
|
|
|
const _FillIndicatorFeature({
|
|
required this.min,
|
|
required this.max,
|
|
this.invalidValue = 0.0,
|
|
this.color,
|
|
});
|
|
|
|
@override
|
|
Iterable<Widget> buildBackground(TextFieldState state) sync* {
|
|
yield _FillIndicatorBar(
|
|
controller: state.effectiveController,
|
|
min: min,
|
|
max: max,
|
|
invalidValue: invalidValue,
|
|
color: color,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FillIndicatorBar extends StatelessWidget {
|
|
final TextEditingController controller;
|
|
final double min;
|
|
final double max;
|
|
final double invalidValue;
|
|
final Color? color;
|
|
|
|
const _FillIndicatorBar({
|
|
required this.controller,
|
|
required this.min,
|
|
required this.max,
|
|
required this.invalidValue,
|
|
required this.color,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = GarageTheme.of(context).colorScheme;
|
|
final fillColor = color ?? scheme.primary;
|
|
final span = max - min;
|
|
|
|
return ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: controller,
|
|
builder: (context, value, _) {
|
|
final parsed = double.tryParse(value.text) ?? invalidValue;
|
|
final fraction = span <= 0
|
|
? 0.0
|
|
: ((parsed - min) / span).clamp(0.0, 1.0);
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FractionallySizedBox(
|
|
widthFactor: fraction,
|
|
heightFactor: 1.0,
|
|
child: ColoredBox(color: fillColor),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---- drag-to-scrub ---------------------------------------------------
|
|
|
|
class _ScrubFeature extends InputFeature {
|
|
final double sensitivity;
|
|
final int decimals;
|
|
final TextAlign? textAlign;
|
|
final Widget? icon;
|
|
final VoidCallback? onStart;
|
|
final ValueChanged<String>? onUpdate;
|
|
final VoidCallback? onEnd;
|
|
|
|
const _ScrubFeature({
|
|
this.sensitivity = 0.1,
|
|
this.decimals = 1,
|
|
this.textAlign,
|
|
this.icon,
|
|
this.onStart,
|
|
this.onUpdate,
|
|
this.onEnd,
|
|
});
|
|
|
|
@override
|
|
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
|
|
if (!state.widget.enabled || state.widget.readOnly) return;
|
|
yield icon ?? const Icon(LucideIcons.chevrons_up_down);
|
|
}
|
|
}
|
|
|
|
// ---- custom leading / trailing ------------------------------------------
|
|
|
|
class _LeadingFeature extends InputFeature {
|
|
final Widget child;
|
|
const _LeadingFeature(this.child);
|
|
|
|
@override
|
|
Iterable<Widget> buildLeading(TextFieldState state) sync* {
|
|
yield child;
|
|
}
|
|
}
|
|
|
|
class _TrailingFeature extends InputFeature {
|
|
final Widget child;
|
|
const _TrailingFeature(this.child);
|
|
|
|
@override
|
|
Iterable<Widget> buildTrailing(TextFieldState state) sync* {
|
|
yield child;
|
|
}
|
|
}
|
|
|
|
// ---- the field itself ----------------------------------------------------
|
|
|
|
/// A single line text input styled to match the rest of the app.
|
|
///
|
|
/// This is intentionally a lot smaller than the shadcn original — it carries
|
|
/// the params the app actually passes plus a few obvious ones. See the family
|
|
/// notes for the pile of exotic params that were dropped.
|
|
class TextField extends StatefulWidget {
|
|
final TextEditingController? controller;
|
|
final String? initialValue;
|
|
final FocusNode? focusNode;
|
|
final Widget? placeholder;
|
|
final bool enabled;
|
|
final bool autofocus;
|
|
final bool readOnly;
|
|
final bool obscureText;
|
|
final String obscuringCharacter;
|
|
final int? maxLines;
|
|
final int? minLines;
|
|
final bool expands;
|
|
final TextAlign textAlign;
|
|
final TextAlignVertical? textAlignVertical;
|
|
final CrossAxisAlignment crossAxisAlignment;
|
|
final TextStyle? style;
|
|
final TextInputType? keyboardType;
|
|
final TextInputAction? textInputAction;
|
|
final TextCapitalization textCapitalization;
|
|
|
|
// handed straight to the EditableText so the platform can offer a saved
|
|
// value. mostly a no-op in the editor, but the web apps need it: it's what
|
|
// drives password-manager fill, and the "webauthn" hint is what lets a
|
|
// browser offer a passkey from inside the field itself.
|
|
final Iterable<String>? autofillHints;
|
|
final List<TextInputFormatter>? inputFormatters;
|
|
final List<TextInputFormatter>? submitFormatters;
|
|
final ValueChanged<String>? onChanged;
|
|
final ValueChanged<String>? onSubmitted;
|
|
final VoidCallback? onEditingComplete;
|
|
final GestureTapCallback? onTap;
|
|
final TapRegionCallback? onTapOutside;
|
|
final List<InputFeature> features;
|
|
final TextFieldVariant variant;
|
|
final EdgeInsetsGeometry? padding;
|
|
final BorderRadiusGeometry? borderRadius;
|
|
final Border? border;
|
|
final bool? filled;
|
|
final BoxDecoration? decoration;
|
|
final Color? cursorColor;
|
|
final double cursorWidth;
|
|
final double? cursorHeight;
|
|
final Radius cursorRadius;
|
|
final bool enableInteractiveSelection;
|
|
|
|
/// screen reader label for the field. The placeholder is not a substitute —
|
|
/// it disappears the moment anything is typed, so a field labelled only by
|
|
/// its placeholder goes anonymous exactly when you need to know what you're
|
|
/// editing.
|
|
final String? semanticLabel;
|
|
|
|
const TextField({
|
|
super.key,
|
|
this.controller,
|
|
this.initialValue,
|
|
this.focusNode,
|
|
this.placeholder,
|
|
this.enabled = true,
|
|
this.autofocus = false,
|
|
this.readOnly = false,
|
|
this.obscureText = false,
|
|
this.obscuringCharacter = "•",
|
|
this.maxLines = 1,
|
|
this.minLines,
|
|
this.expands = false,
|
|
this.textAlign = TextAlign.start,
|
|
this.textAlignVertical,
|
|
this.crossAxisAlignment = CrossAxisAlignment.center,
|
|
this.style,
|
|
this.keyboardType,
|
|
this.textInputAction,
|
|
this.textCapitalization = TextCapitalization.none,
|
|
this.autofillHints,
|
|
this.inputFormatters,
|
|
this.submitFormatters = const [],
|
|
this.onChanged,
|
|
this.onSubmitted,
|
|
this.onEditingComplete,
|
|
this.onTap,
|
|
this.onTapOutside,
|
|
this.features = const [],
|
|
this.variant = TextFieldVariant.outline,
|
|
this.padding,
|
|
this.borderRadius,
|
|
this.border,
|
|
this.filled,
|
|
this.decoration,
|
|
this.cursorColor,
|
|
this.cursorWidth = 2.0,
|
|
this.cursorHeight,
|
|
this.cursorRadius = const Radius.circular(2.0),
|
|
this.enableInteractiveSelection = true,
|
|
this.semanticLabel,
|
|
});
|
|
|
|
@override
|
|
State<TextField> createState() => TextFieldState();
|
|
}
|
|
|
|
class TextFieldState extends State<TextField>
|
|
implements TextSelectionGestureDetectorBuilderDelegate {
|
|
TextEditingController? _localController;
|
|
TextEditingController get effectiveController =>
|
|
widget.controller ?? _localController!;
|
|
|
|
FocusNode? _localFocus;
|
|
FocusNode get _effectiveFocusNode =>
|
|
widget.focusNode ?? (_localFocus ??= FocusNode());
|
|
|
|
late _FieldSelectionGestureDetectorBuilder _gestureBuilder;
|
|
bool _showSelectionHandles = false;
|
|
bool _hovered = false;
|
|
|
|
// finds the InputFeature.scrub in the feature list, if any. features are a
|
|
// plain list not a set, so this is a linear scan - fine, that list is
|
|
// never more than a handful of entries long.
|
|
_ScrubFeature? get _scrubFeature {
|
|
for (final feature in widget.features) {
|
|
if (feature is _ScrubFeature) return feature;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// drag-to-scrub tracking - see [InputFeature.scrub]. Uses raw Listener
|
|
// pointer events rather than GestureDetector's onTap+onPan* - those are
|
|
// two separately-competing recognizers in the same arena, and that
|
|
// combination was unreliable in practice (tap kept losing to pan even for
|
|
// a genuine click). Listener isn't a recognizer at all, so theres no arena
|
|
// to lose - it always fires, and WE decide tap vs drag from the
|
|
// accumulated movement ourselves.
|
|
static const _scrubClickThreshold = 3.0;
|
|
double _scrubLiveValue = 0;
|
|
double _scrubDistance = 0;
|
|
bool _scrubDragStarted = false;
|
|
|
|
/// Whether the value in here is not yours to change - either the field is
|
|
/// disabled outright, or its readOnly and only there to be looked at.
|
|
bool get _dimmed => widget.readOnly || !widget.enabled;
|
|
|
|
void _scrubPointerDown(PointerDownEvent event) {
|
|
if (widget.readOnly) return;
|
|
_scrubDistance = 0;
|
|
_scrubDragStarted = false;
|
|
_scrubLiveValue = double.tryParse(effectiveController.text) ?? 0;
|
|
}
|
|
|
|
void _scrubPointerMove(PointerMoveEvent event) {
|
|
if (widget.readOnly) return;
|
|
final feature = _scrubFeature;
|
|
if (feature == null) return;
|
|
if (_scrubDragStarted) {
|
|
// with a native cursor lock the cursor is pinned and raw deltas arrive
|
|
// via CursorLock.lock's callback instead - Flutters own pointer
|
|
// position is meaningless from here on (it'd just be measuring
|
|
// distance to the anchor, not real movement). Without one (web, and
|
|
// anywhere else with no native handler) nothing is pinned and nothing
|
|
// is swallowed, so the pointer stream IS the delta source. The cursor
|
|
// just walks off the field as you drag, which is the best thats
|
|
// available there.
|
|
if (!CursorLock.isSupported) _applyScrubDelta(feature, event.delta.dx);
|
|
return;
|
|
}
|
|
_scrubDistance += event.delta.dx.abs() + event.delta.dy.abs();
|
|
if (_scrubDistance < _scrubClickThreshold) return;
|
|
_scrubDragStarted = true;
|
|
feature.onStart?.call();
|
|
CursorLock.lock((dx, dy) => _applyScrubDelta(feature, dx));
|
|
}
|
|
|
|
void _applyScrubDelta(_ScrubFeature feature, double dx) {
|
|
final sensitivity = HardwareKeyboard.instance.isShiftPressed
|
|
? feature.sensitivity * 0.1
|
|
: feature.sensitivity;
|
|
_scrubLiveValue += dx * sensitivity;
|
|
final text = _scrubLiveValue.toStringAsFixed(feature.decimals);
|
|
setState(() => effectiveController.text = text);
|
|
feature.onUpdate?.call(text);
|
|
}
|
|
|
|
void _scrubPointerUp(PointerUpEvent event) {
|
|
if (widget.readOnly) return;
|
|
if (_scrubDragStarted) {
|
|
CursorLock.unlock();
|
|
_scrubFeature?.onEnd?.call();
|
|
_scrubDragStarted = false;
|
|
return;
|
|
}
|
|
// never crossed the movement threshold - a click, not a drag. select-all
|
|
// and focus - the field's real EditableText is always in the tree now
|
|
// (see [showScrub] below), so the focus node is already attached and
|
|
// this takes effect immediately, same as clicking any other field.
|
|
effectiveController.selection = TextSelection(
|
|
baseOffset: 0,
|
|
extentOffset: effectiveController.text.length,
|
|
);
|
|
_effectiveFocusNode.requestFocus();
|
|
}
|
|
|
|
@override
|
|
final GlobalKey<EditableTextState> editableTextKey =
|
|
GlobalKey<EditableTextState>();
|
|
|
|
@override
|
|
bool get forcePressEnabled => true;
|
|
|
|
@override
|
|
bool get selectionEnabled =>
|
|
widget.enableInteractiveSelection && widget.enabled;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_gestureBuilder = _FieldSelectionGestureDetectorBuilder(state: this);
|
|
if (widget.controller == null) {
|
|
_localController = widget.initialValue != null
|
|
? TextEditingController(text: widget.initialValue)
|
|
: TextEditingController();
|
|
}
|
|
_effectiveFocusNode.canRequestFocus = widget.enabled;
|
|
_effectiveFocusNode.addListener(_handleFocusChanged);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(TextField oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (widget.controller == null && oldWidget.controller != null) {
|
|
_localController = TextEditingController.fromValue(
|
|
oldWidget.controller!.value,
|
|
);
|
|
} else if (widget.controller != null && oldWidget.controller == null) {
|
|
_localController?.dispose();
|
|
_localController = null;
|
|
}
|
|
if (widget.focusNode != oldWidget.focusNode) {
|
|
(oldWidget.focusNode ?? _localFocus)?.removeListener(_handleFocusChanged);
|
|
(widget.focusNode ?? _localFocus)?.addListener(_handleFocusChanged);
|
|
}
|
|
_effectiveFocusNode.canRequestFocus = widget.enabled;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// dont leave the OS cursor hidden/pinned forever if this field gets torn
|
|
// down mid-drag (e.g. its panel closes while youre scrubbing).
|
|
if (_scrubDragStarted) CursorLock.unlock();
|
|
_effectiveFocusNode.removeListener(_handleFocusChanged);
|
|
_localFocus?.dispose();
|
|
_localController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
EditableTextState get _editableText => editableTextKey.currentState!;
|
|
void _requestKeyboard() => _editableText.requestKeyboard();
|
|
|
|
void _handleFocusChanged() {
|
|
setState(() {});
|
|
if (!_effectiveFocusNode.hasFocus) {
|
|
_formatSubmit();
|
|
}
|
|
}
|
|
|
|
// run submitFormatters when the field loses focus / on submit, same as shadcn
|
|
void _formatSubmit() {
|
|
final formatters = widget.submitFormatters;
|
|
if (formatters == null || formatters.isEmpty) return;
|
|
try {
|
|
var value = effectiveController.value;
|
|
for (final formatter in formatters) {
|
|
value = formatter.formatEditUpdate(value, value);
|
|
}
|
|
if (value != effectiveController.value) {
|
|
effectiveController.value = value;
|
|
widget.onChanged?.call(value.text);
|
|
}
|
|
} catch (e, st) {
|
|
// dont eat it silently, but dont blow up the field either
|
|
debugPrint("TextField submitFormatters failed: $e\n$st");
|
|
}
|
|
}
|
|
|
|
bool get _hasDecoration =>
|
|
widget.placeholder != null || widget.features.isNotEmpty;
|
|
|
|
TextAlignVertical get _textAlignVertical {
|
|
if (widget.textAlignVertical != null) return widget.textAlignVertical!;
|
|
return _hasDecoration ? TextAlignVertical.center : TextAlignVertical.top;
|
|
}
|
|
|
|
bool _shouldShowSelectionHandles(SelectionChangedCause? cause) {
|
|
if (!_gestureBuilder.shouldShowSelectionToolbar) return false;
|
|
if (effectiveController.selection.isCollapsed) return false;
|
|
if (cause == SelectionChangedCause.keyboard) return false;
|
|
if (effectiveController.text.isNotEmpty) return true;
|
|
return false;
|
|
}
|
|
|
|
void _handleSelectionChanged(
|
|
TextSelection selection,
|
|
SelectionChangedCause? cause,
|
|
) {
|
|
final willShow = _shouldShowSelectionHandles(cause);
|
|
if (willShow != _showSelectionHandles) {
|
|
setState(() => _showSelectionHandles = willShow);
|
|
}
|
|
if (cause == SelectionChangedCause.drag) {
|
|
_editableText.hideToolbar();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = GarageTheme.of(context);
|
|
final scheme = theme.colorScheme;
|
|
|
|
// controlGap, not baseGap - this is the icon-to-label gap inside the field.
|
|
final densityGap = theme.density.controlGap;
|
|
final textDirection = Directionality.of(context);
|
|
|
|
// text style: control type -> normal(w400) -> foreground -> user.
|
|
//
|
|
// typography.small, NOT the ambient DefaultTextStyle. The root UI style is
|
|
// 11px with no `height`, so a field built from it got a line box of
|
|
// 11 * whatever line-height ratio the font happens to have (~1.25 for
|
|
// Geist = ~13.75px) against a button's exact 11px - fields came out ~26px
|
|
// tall where every other control is 23, and the only one that looked right
|
|
// was the explorer search box because a SizedBox was clamping it.
|
|
//
|
|
// Note `height` is a multiple of fontSize, not of the font's own metrics,
|
|
// so pinning it here makes the control box font-independent - which is also
|
|
// what lets the geometry tests mean anything (the test host doesnt resolve
|
|
// Geist, so a style without an explicit height measures differently there
|
|
// than it does in the app).
|
|
// inside a property row the variant isnt the call sites to pick - see
|
|
// PropertySlotScope. explicit loses here, deliberately.
|
|
final variant = PropertySlotScope.of(context)
|
|
? TextFieldVariant.secondary
|
|
: widget.variant;
|
|
final variantForeground = variant == TextFieldVariant.secondary
|
|
? scheme.secondaryForeground
|
|
: scheme.foreground;
|
|
// plain `small`, which is w400. Controls used to merge typography.medium
|
|
// over it and render at w500 - two steps above the w300 body copy they
|
|
// sit next to, which read as bold rather than as emphasis. The whole
|
|
// control family moved together, so a field still matches the buttons
|
|
// and selects beside it.
|
|
TextStyle textStyle = theme.typography.small.copyWith(
|
|
color: variantForeground,
|
|
);
|
|
if (widget.style != null) textStyle = textStyle.merge(widget.style);
|
|
// one rule for "you cant change this", used by the text AND by the icons
|
|
// in the field's feature slots. It was readOnly for the text and nothing
|
|
// at all for the icons, so a disabled field dimmed its value and left a
|
|
// full-brightness lock or calendar sitting next to it.
|
|
if (_dimmed) {
|
|
textStyle = textStyle.copyWith(color: scheme.mutedForeground);
|
|
}
|
|
|
|
final cursorColor = widget.cursorColor ?? scheme.primary;
|
|
final selectionColor = scheme.primary.withValues(alpha: 0.2);
|
|
|
|
// a row holding this one can say the value was rejected. It doesnt change
|
|
// the border, it fades a destructive one in over the top - see
|
|
// FieldErrorOutline for why that isnt the same as swapping the colour.
|
|
final invalid = FieldErrorScope.of(context);
|
|
final effectiveBorder =
|
|
widget.border ??
|
|
Border.all(
|
|
color: scheme.controlBorder,
|
|
width: theme.density.controlBorderWidth,
|
|
);
|
|
|
|
final baseRadius =
|
|
widget.borderRadius?.resolve(textDirection) ??
|
|
BorderRadius.circular(theme.radiusMd);
|
|
final group = ButtonGroupScope.maybeOf(context);
|
|
final resolvedRadius = group == null
|
|
? baseRadius
|
|
: group.corners.applyTo(baseRadius, textDirection);
|
|
|
|
final focused = _effectiveFocusNode.hasFocus;
|
|
final scrubFeature = _scrubFeature;
|
|
// while unfocused, drag-to-scrub fields swap the gesture LAYER (pan
|
|
// instead of the normal text-selection gestures) so scrubbing doesnt
|
|
// fight the EditableText's own drag-to-select - but its still the exact
|
|
// same EditableText underneath either way (see `content` below). That's
|
|
// deliberate: an entirely separate display widget for the unfocused
|
|
// state was what caused the corner-radius/height/offset mismatches -
|
|
// two different widgets are never guaranteed to lay out identically no
|
|
// matter how carefully their style/padding is matched.
|
|
final showScrub = scrubFeature != null && widget.enabled && !focused;
|
|
final cursor = !widget.enabled
|
|
? SystemMouseCursors.forbidden
|
|
: widget.readOnly
|
|
? SystemMouseCursors.basic
|
|
: showScrub
|
|
? SystemMouseCursors.resizeLeftRight
|
|
: SystemMouseCursors.text;
|
|
|
|
final Color fieldColor = switch (variant) {
|
|
TextFieldVariant.secondary => scheme.secondary,
|
|
TextFieldVariant.outline =>
|
|
(widget.filled ?? false)
|
|
? scheme.muted
|
|
: focused
|
|
? scheme.controlFillFocused
|
|
: (_hovered && !widget.readOnly)
|
|
? scheme.controlFillHovered
|
|
: scheme.controlFill,
|
|
};
|
|
Decoration decoration =
|
|
widget.decoration ??
|
|
BoxDecoration(
|
|
borderRadius: resolvedRadius,
|
|
color: fieldColor,
|
|
border: effectiveBorder,
|
|
);
|
|
|
|
if (group != null && decoration is BoxDecoration) {
|
|
final border = decoration.border;
|
|
if (border is Border) {
|
|
decoration = decoration.copyWith(
|
|
border: group.mergedBorder(border, textDirection),
|
|
);
|
|
}
|
|
}
|
|
|
|
// (ButtonGroup style-override dropped - nothing in the app stacks fields)
|
|
|
|
final padding = widget.padding ?? theme.density.textFieldPadding;
|
|
|
|
final formatters = <TextInputFormatter>[...?widget.inputFormatters];
|
|
|
|
// container: false on purpose - this annotates the EditableText's own
|
|
// node rather than wrapping it in a second one, so the field keeps the
|
|
// per-line/selection semantics that text editing needs and just gains a
|
|
// name. Merging here would flatten those, which is worse than no label.
|
|
final editable = Semantics(
|
|
label: resolveSemanticLabel(context, widget.semanticLabel),
|
|
enabled: widget.enabled,
|
|
// Once the semantics tree is on, flutter web lays a real <input> over
|
|
// the field and the click lands on THAT, not on our gesture detector.
|
|
// All the browser does then is ask through semantics - and with no
|
|
// handler here, nobody answered and the field never took focus.
|
|
// Same two handlers material's TextField has.
|
|
onTap: widget.readOnly || !widget.enabled
|
|
? null
|
|
: () {
|
|
final c = effectiveController;
|
|
if (!c.selection.isValid) {
|
|
c.selection = TextSelection.collapsed(offset: c.text.length);
|
|
}
|
|
_effectiveFocusNode.requestFocus();
|
|
_requestKeyboard();
|
|
},
|
|
onFocus: widget.enabled
|
|
? () {
|
|
if (!_effectiveFocusNode.hasFocus) {
|
|
_effectiveFocusNode.requestFocus();
|
|
} else if (!widget.readOnly) {
|
|
// the platform thinks were not focused even though we are,
|
|
// so the keyboard probly went away. ask for it again
|
|
_requestKeyboard();
|
|
}
|
|
}
|
|
: null,
|
|
child: RepaintBoundary(
|
|
child: EditableText(
|
|
key: editableTextKey,
|
|
controller: effectiveController,
|
|
focusNode: _effectiveFocusNode,
|
|
readOnly: widget.readOnly || !widget.enabled,
|
|
showSelectionHandles: _showSelectionHandles,
|
|
keyboardType: widget.keyboardType,
|
|
textInputAction: widget.textInputAction,
|
|
textCapitalization: widget.textCapitalization,
|
|
autofillHints: widget.autofillHints,
|
|
style: textStyle,
|
|
// while showing the scrub display, this can differ from the normal
|
|
// alignment (e.g. centered at rest, left-aligned once editing) -
|
|
// still the same EditableText either way, just told to align its
|
|
// text differently.
|
|
textAlign: showScrub
|
|
? (scrubFeature!.textAlign ?? widget.textAlign)
|
|
: widget.textAlign,
|
|
autofocus: widget.autofocus,
|
|
obscuringCharacter: widget.obscuringCharacter,
|
|
obscureText: widget.obscureText,
|
|
maxLines: widget.maxLines,
|
|
minLines: widget.minLines,
|
|
expands: widget.expands,
|
|
selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null,
|
|
selectionControls: selectionEnabled
|
|
? cupertinoDesktopTextSelectionHandleControls
|
|
: null,
|
|
onChanged: (v) => widget.onChanged?.call(v),
|
|
onSelectionChanged: _handleSelectionChanged,
|
|
onEditingComplete: () {
|
|
widget.onEditingComplete?.call();
|
|
_formatSubmit();
|
|
},
|
|
onSubmitted: (v) {
|
|
widget.onSubmitted?.call(v);
|
|
_formatSubmit();
|
|
},
|
|
onTapOutside: widget.onTapOutside,
|
|
inputFormatters: formatters,
|
|
rendererIgnoresPointer: true,
|
|
mouseCursor: cursor,
|
|
cursorWidth: widget.cursorWidth,
|
|
cursorHeight: widget.cursorHeight,
|
|
cursorRadius: widget.cursorRadius,
|
|
cursorColor: cursorColor,
|
|
cursorOpacityAnimates: true,
|
|
paintCursorAboveText: true,
|
|
backgroundCursorColor: scheme.border,
|
|
selectionHeightStyle: BoxHeightStyle.tight,
|
|
selectionWidthStyle: BoxWidthStyle.tight,
|
|
scrollPadding: const EdgeInsets.all(20.0),
|
|
dragStartBehavior: DragStartBehavior.start,
|
|
enableInteractiveSelection: widget.enableInteractiveSelection,
|
|
clipBehavior: Clip.none,
|
|
),
|
|
),
|
|
);
|
|
|
|
// the exact same content either way - only the gesture wrapper around it
|
|
// differs below. This is deliberate: keeping ONE render tree for both
|
|
// scrub and editing states is what guarantees they can never mismatch
|
|
// (radius/height/text-offset) - two visually-matched-by-hand widgets
|
|
// never actually guarantee identical layout, as the last few rounds of
|
|
// whack-a-mole fixes here demonstrated.
|
|
final content = Align(
|
|
alignment: Alignment(-1.0, _textAlignVertical.y),
|
|
// drag-to-scrub fields fill the full row width instead of shrink-
|
|
// wrapping to content, so a centered scrubTextAlign actually centers
|
|
// in the box rather than in the text's own tight bounds.
|
|
widthFactor: scrubFeature != null ? null : 1.0,
|
|
heightFactor: 1.0,
|
|
child: Builder(
|
|
builder: (context) {
|
|
final inset = padding.resolve(textDirection);
|
|
|
|
// Only the HORIZONTAL inset wraps the whole row. The vertical half
|
|
// is pushed inward onto the text alone (see _buildContent), because
|
|
// otherwise it stacks on top of whatever a feature brought with it:
|
|
// a trailing IconButton is already a full control tall, so the field
|
|
// came out at the button's height PLUS its own padding - 23 + 12 =
|
|
// 35 against a controlHeight of 23. Sitting inside the padding
|
|
// instead, the button measures the same 23 as the padded text and
|
|
// the field stops growing.
|
|
return Padding(
|
|
padding: EdgeInsets.only(left: inset.left, right: inset.right),
|
|
child: _buildContent(
|
|
editable,
|
|
textStyle,
|
|
theme,
|
|
densityGap,
|
|
EdgeInsets.only(top: inset.top, bottom: inset.bottom),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
final fieldContent = showScrub
|
|
? Listener(
|
|
behavior: HitTestBehavior.translucent,
|
|
onPointerDown: _scrubPointerDown,
|
|
onPointerMove: _scrubPointerMove,
|
|
onPointerUp: _scrubPointerUp,
|
|
child: content,
|
|
)
|
|
: _gestureBuilder.buildGestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
child: content,
|
|
);
|
|
|
|
// background layer (e.g. InputFeature.fillIndicator) sits behind the
|
|
// text but inside the same clipped/rounded border, so it never needs its
|
|
// own radius handling.
|
|
final backgrounds = <Widget>[];
|
|
for (final feature in widget.features) {
|
|
backgrounds.addAll(feature.buildBackground(this));
|
|
}
|
|
|
|
Widget field = MouseRegion(
|
|
cursor: cursor,
|
|
onEnter: (_) {
|
|
if (!_hovered) setState(() => _hovered = true);
|
|
},
|
|
onExit: (_) {
|
|
if (_hovered) setState(() => _hovered = false);
|
|
},
|
|
// no focus ring - the fill colour signals focus instead.
|
|
child: IconTheme.merge(
|
|
data: IconThemeData(
|
|
size: theme.iconTheme.small.size,
|
|
color: _dimmed
|
|
? scheme.mutedForeground
|
|
: variant == TextFieldVariant.secondary
|
|
? scheme.secondaryForeground
|
|
: scheme.mutedForeground,
|
|
),
|
|
child: TextFieldTapRegion(
|
|
child: IgnorePointer(
|
|
ignoring: !widget.enabled,
|
|
child: FieldErrorOutline(
|
|
invalid: invalid,
|
|
borderRadius: resolvedRadius,
|
|
child: Container(
|
|
clipBehavior: Clip.antiAlias,
|
|
decoration: decoration,
|
|
// backgrounds must be Positioned, not plain Stack children -
|
|
// otherwise theyd count as non-positioned and get pulled into
|
|
// the Stack's own size computation, which blows up with an
|
|
// infinite-height crash the moment this field sits somewhere
|
|
// with unbounded height (e.g. a mainAxisSize.min Column).
|
|
child: backgrounds.isEmpty
|
|
? fieldContent
|
|
: Stack(
|
|
children: [
|
|
// a full-width background (InputFeature.fillIndicator)
|
|
// runs right into the corners, so it needs the fields
|
|
// own radius applied to it directly - resolvedRadius,
|
|
// not baseRadius, so a field inside a ButtonGroup
|
|
// still only rounds the corners the group rounds.
|
|
Positioned.fill(
|
|
child: ClipRRect(
|
|
borderRadius: resolvedRadius,
|
|
child: Stack(
|
|
children: [
|
|
for (final background in backgrounds)
|
|
Positioned.fill(child: background),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
fieldContent,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// gather prefix/suffix (stepper buttons that hang off the sides)
|
|
final prefixes = <Widget>[];
|
|
final suffixes = <Widget>[];
|
|
for (final feature in widget.features) {
|
|
prefixes.addAll(feature.buildPrefix(this));
|
|
suffixes.addAll(feature.buildSuffix(this));
|
|
}
|
|
|
|
if (prefixes.isEmpty && suffixes.isEmpty) {
|
|
return field;
|
|
}
|
|
|
|
return ButtonGroup.horizontal(
|
|
children: [
|
|
...prefixes,
|
|
Flexible(child: field),
|
|
...suffixes,
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(
|
|
Widget editable,
|
|
TextStyle textStyle,
|
|
ThemeData theme,
|
|
double densityGap,
|
|
EdgeInsets textInset,
|
|
) {
|
|
return ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: effectiveController,
|
|
child: editable,
|
|
builder: (context, value, child) {
|
|
final hasText = value.text.isNotEmpty;
|
|
|
|
final placeholder = widget.placeholder == null
|
|
? null
|
|
: Visibility(
|
|
maintainAnimation: true,
|
|
maintainSize: true,
|
|
maintainState: true,
|
|
visible: !hasText,
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: DefaultTextStyle(
|
|
// textStyle already resolved small/normal/user style; just
|
|
// recolour it. (re-merging typography.small here clobbered
|
|
// any custom fontSize, leaving the placeholder stuck at 14.)
|
|
style: textStyle.copyWith(
|
|
color: theme.colorScheme.mutedForeground,
|
|
),
|
|
textAlign: widget.textAlign,
|
|
maxLines: widget.maxLines,
|
|
child: widget.placeholder!,
|
|
),
|
|
),
|
|
);
|
|
|
|
final leadingChildren = <Widget>[];
|
|
final trailingChildren = <Widget>[];
|
|
for (final feature in widget.features) {
|
|
leadingChildren.addAll(feature.buildLeading(this));
|
|
trailingChildren.addAll(feature.buildTrailing(this));
|
|
}
|
|
|
|
final isMultiline =
|
|
widget.expands ||
|
|
widget.maxLines == null ||
|
|
(widget.maxLines != null && widget.maxLines! > 1) ||
|
|
(widget.minLines != null && widget.minLines! > 1);
|
|
final placeholderAlignment = AlignmentDirectional(
|
|
0.0,
|
|
isMultiline ? -1.0 : 0.0,
|
|
);
|
|
|
|
// Feature children - an axis label, a "px" suffix - get dropped into
|
|
// this Row raw, so without this they measure against the AMBIENT
|
|
// DefaultTextStyle: 11px with no `height`, i.e. whatever line-height
|
|
// ratio the font fancies (~1.25 in Geist = ~13.75px). The Row takes
|
|
// its tallest child, so one bare Text was quietly making the whole
|
|
// field ~26 next to a 23 colour swatch in the same properties row.
|
|
// Same style the EditableText below runs on, for the same reason the
|
|
// placeholder gets it.
|
|
// merge, not replace: typography.small carries no fontFamily, so
|
|
// replacing outright would drop the ambient Geist and render the
|
|
// labels in the platform default.
|
|
// Leading and trailing features stay pinned to their own edges. When
|
|
// the text is centred, reserving the same width on the empty side is
|
|
// what centres it across the FIELD rather than across the space the
|
|
// features left behind - without it, one leading icon throws the text
|
|
// off centre by half the icon plus the gap.
|
|
//
|
|
// A plain spacer, not a copy of the feature: a duplicated widget sits
|
|
// in the tree twice, which broke a test looking for a single icon
|
|
// inside a field.
|
|
final centred = widget.textAlign == TextAlign.center;
|
|
final featureSlot = theme.iconTheme.small.size ?? 0;
|
|
final mirrorTrailing =
|
|
centred && leadingChildren.isNotEmpty && trailingChildren.isEmpty;
|
|
final mirrorLeading =
|
|
centred && trailingChildren.isNotEmpty && leadingChildren.isEmpty;
|
|
|
|
return DefaultTextStyle.merge(
|
|
style: textStyle,
|
|
child: Row(
|
|
crossAxisAlignment: widget.crossAxisAlignment,
|
|
spacing: densityGap,
|
|
children: [
|
|
if (mirrorLeading) SizedBox(width: featureSlot),
|
|
if (leadingChildren.isNotEmpty)
|
|
_featureSlot(
|
|
theme,
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
spacing: densityGap,
|
|
children: leadingChildren,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: textInset,
|
|
child:
|
|
// Padding/border give the control its outer height; the offset
|
|
// only nudges the ink so a field label lands on the same baseline
|
|
// as a button label. It wraps the whole Stack, never the
|
|
// placeholder on its own - otherwise the text would hop the
|
|
// moment you type.
|
|
_optionallyNudged(
|
|
theme.density.controlTextOffset,
|
|
Stack(
|
|
alignment: placeholderAlignment,
|
|
children: [?placeholder, child!],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (trailingChildren.isNotEmpty)
|
|
_featureSlot(
|
|
theme,
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
spacing: densityGap,
|
|
children: trailingChildren,
|
|
),
|
|
),
|
|
if (mirrorTrailing) SizedBox(width: featureSlot),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// A feature sits INSIDE the field, so one control tall is its ceiling - it is
|
|
// never the thing that decides how tall the field is. Two ways that bit us
|
|
// without this: a trailing IconButton measured a couple of px over the text it
|
|
// sits next to and dragged the field up with it, and the stepper's AspectRatio
|
|
// - which has no natural height of its own - saw the unbounded constraint the
|
|
// row now hands down and grew to fill the whole viewport.
|
|
Widget _featureSlot(ThemeData theme, Widget child) {
|
|
// controlHeight MINUS the border: the stroke is drawn outside this content
|
|
// box (which is why textFieldPadding subtracts it too), so capping at the
|
|
// full control height would let a feature push the field two px over.
|
|
final d = theme.density;
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: d.controlHeight - d.controlBorderWidth * 2,
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
class _FieldSelectionGestureDetectorBuilder
|
|
extends TextSelectionGestureDetectorBuilder {
|
|
_FieldSelectionGestureDetectorBuilder({required TextFieldState state})
|
|
: _state = state,
|
|
super(delegate: state);
|
|
|
|
final TextFieldState _state;
|
|
|
|
@override
|
|
void onSingleTapUp(TapDragUpDetails details) {
|
|
super.onSingleTapUp(details);
|
|
_state.widget.onTap?.call();
|
|
}
|
|
|
|
@override
|
|
void onDragSelectionEnd(TapDragEndDetails details) {
|
|
_state._requestKeyboard();
|
|
super.onDragSelectionEnd(details);
|
|
}
|
|
}
|
|
|
|
// shadcn shipped a few canned TextInputFormatters. the app only uses the math
|
|
// expression one (for the numeric fields that accept "12+3*2" style input).
|
|
class TextInputFormatters {
|
|
TextInputFormatters._();
|
|
|
|
// allow digits, the basic operators, brackets, a decimal point and spaces.
|
|
static TextInputFormatter mathExpression() {
|
|
return FilteringTextInputFormatter.allow(RegExp(r"[0-9+\-*/().%\s]"));
|
|
}
|
|
|
|
// resolves a typed expression on commit ("12+4" -> "16") and leaves it alone
|
|
// otherwise. for fields that dont already reformat their own text on
|
|
// commit - most of them do their own tryParse+reformat and just call
|
|
// evaluateMathExpression() directly instead of pulling this in.
|
|
static TextInputFormatter mathExpressionEvaluator() {
|
|
return _MathExpressionEvaluatorFormatter();
|
|
}
|
|
}
|
|
|
|
class _MathExpressionEvaluatorFormatter extends TextInputFormatter {
|
|
@override
|
|
TextEditingValue formatEditUpdate(
|
|
TextEditingValue oldValue,
|
|
TextEditingValue newValue,
|
|
) {
|
|
final text = newValue.text.trim();
|
|
// already a plain number - leave it exactly as typed, dont go normalising
|
|
// trailing zeroes etc on stuff that was never an expression to begin with
|
|
if (double.tryParse(text) != null) return newValue;
|
|
|
|
final resolved = evaluateMathExpression(text);
|
|
if (resolved == null) return newValue;
|
|
|
|
final resolvedText = _formatResolvedNumber(resolved);
|
|
return TextEditingValue(
|
|
text: resolvedText,
|
|
selection: TextSelection.collapsed(offset: resolvedText.length),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _formatResolvedNumber(double value) {
|
|
if (value == value.roundToDouble() && value.abs() < 1e15) {
|
|
return value.toInt().toString();
|
|
}
|
|
var text = value.toStringAsFixed(4);
|
|
while (text.endsWith("0")) {
|
|
text = text.substring(0, text.length - 1);
|
|
}
|
|
if (text.endsWith(".")) text = text.substring(0, text.length - 1);
|
|
return text;
|
|
}
|
|
|
|
/// Parses a plain number OR a small arithmetic expression - digits, `+ - * /`,
|
|
/// parens, unary minus. Mirrors double.tryParse: null on anything it cant
|
|
/// make sense of, so callers that already do `double.tryParse(text) ?? ...`
|
|
/// can just swap the function and keep everything else (clamping, reverting
|
|
/// on null, whatever) unchanged.
|
|
///
|
|
/// Deliberately doesnt support `%` - shows up in the char allow-list above
|
|
/// but its ambiguous here (modulo? percent-of-what?) so its just left
|
|
/// unsupported for now rather than guessed at.
|
|
double? evaluateMathExpression(String input) {
|
|
final trimmed = input.trim();
|
|
if (trimmed.isEmpty) return null;
|
|
|
|
// fast path - the overwhelming majority of commits are a plain number,
|
|
// no need to fire up the parser for those
|
|
final direct = double.tryParse(trimmed);
|
|
if (direct != null) return direct;
|
|
|
|
try {
|
|
final parser = _ExprParser(trimmed);
|
|
final value = parser._parseExpression();
|
|
parser._skipSpaces();
|
|
if (!parser._atEnd) return null; // trailing junk after a valid expr
|
|
if (!value.isFinite) return null; // e.g. divide by zero
|
|
return value;
|
|
} on FormatException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// tiny recursive-descent parser, no point pulling in a whole expression
|
|
// package for +-*/() with one level of precedence
|
|
class _ExprParser {
|
|
_ExprParser(this._src);
|
|
|
|
final String _src;
|
|
int _pos = 0;
|
|
|
|
bool get _atEnd => _pos >= _src.length;
|
|
|
|
void _skipSpaces() {
|
|
while (!_atEnd && _src[_pos] == " ") {
|
|
_pos++;
|
|
}
|
|
}
|
|
|
|
String? _peek() => _atEnd ? null : _src[_pos];
|
|
|
|
double _parseExpression() {
|
|
_skipSpaces();
|
|
var value = _parseTerm();
|
|
_skipSpaces();
|
|
while (_peek() == "+" || _peek() == "-") {
|
|
final op = _src[_pos++];
|
|
_skipSpaces();
|
|
final rhs = _parseTerm();
|
|
value = op == "+" ? value + rhs : value - rhs;
|
|
_skipSpaces();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
double _parseTerm() {
|
|
var value = _parseUnary();
|
|
_skipSpaces();
|
|
while (_peek() == "*" || _peek() == "/") {
|
|
final op = _src[_pos++];
|
|
_skipSpaces();
|
|
final rhs = _parseUnary();
|
|
value = op == "*" ? value * rhs : value / rhs;
|
|
_skipSpaces();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
double _parseUnary() {
|
|
_skipSpaces();
|
|
if (_peek() == "-") {
|
|
_pos++;
|
|
return -_parseUnary();
|
|
}
|
|
if (_peek() == "+") {
|
|
_pos++;
|
|
return _parseUnary();
|
|
}
|
|
return _parsePrimary();
|
|
}
|
|
|
|
double _parsePrimary() {
|
|
_skipSpaces();
|
|
if (_peek() == "(") {
|
|
_pos++;
|
|
final value = _parseExpression();
|
|
_skipSpaces();
|
|
if (_peek() != ")") throw const FormatException("expected )");
|
|
_pos++;
|
|
return value;
|
|
}
|
|
|
|
final start = _pos;
|
|
while (!_atEnd && (_isDigit(_src[_pos]) || _src[_pos] == ".")) {
|
|
_pos++;
|
|
}
|
|
if (_pos == start) throw const FormatException("expected a number");
|
|
final value = double.tryParse(_src.substring(start, _pos));
|
|
if (value == null) throw const FormatException("bad number literal");
|
|
return value;
|
|
}
|
|
|
|
bool _isDigit(String ch) {
|
|
final code = ch.codeUnitAt(0);
|
|
return code >= 48 && code <= 57;
|
|
}
|
|
}
|