From 1f1314c4ab6b4eccb77090ed37fe5a6cca594417 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 5 Jun 2024 18:08:26 +0800 Subject: [PATCH] Finish initial working prototype --- CONTRIBUTING.md | 13 + forui/lib/forui.dart | 1 + forui/lib/src/theme/color_scheme.dart | 9 + forui/lib/src/theme/theme_data.dart | 36 +- forui/lib/src/theme/themes.dart | 3 + forui/lib/src/widgets/card/card.dart | 2 +- .../src/widgets/text_field/text_field.dart | 516 ++++++++++++++++++ .../widgets/text_field/text_field_state.dart | 135 +++++ .../widgets/text_field/text_field_style.dart | 221 ++++++++ forui/test/src/theme/color_scheme_test.dart | 4 + 10 files changed, 926 insertions(+), 14 deletions(-) create mode 100644 forui/lib/src/widgets/text_field/text_field.dart create mode 100644 forui/lib/src/widgets/text_field/text_field_state.dart create mode 100644 forui/lib/src/widgets/text_field/text_field_style.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e60e5d81..bf8c6b1a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,4 +138,17 @@ class Foo extends StatelessWidget { * Avoid [double negatives](https://en.wikipedia.org/wiki/Double_negative) when naming things, i.e. a boolean field should be named `enabled` instead of `disabled`. +* Avoid past tense when naming callbacks, prefer present tense instead. + + ✅ Prefer this: + ```dart + final VoidCallback onPress; + ``` + + ❌ Instead of: + ```dart + final VoidCallback onPressed; + ``` + + * Prefix all publicly exported widgets and styles with `F`, i.e. `FScaffold`. diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart index e00a3b873..597ec1d13 100644 --- a/forui/lib/forui.dart +++ b/forui/lib/forui.dart @@ -25,3 +25,4 @@ export 'src/widgets/header/header.dart'; export 'src/widgets/box.dart'; export 'src/widgets/separator.dart'; export 'src/widgets/switch.dart'; +export 'src/widgets/text_field/text_field.dart'; diff --git a/forui/lib/src/theme/color_scheme.dart b/forui/lib/src/theme/color_scheme.dart index 6bb9e06a9..f8e8e4d0d 100644 --- a/forui/lib/src/theme/color_scheme.dart +++ b/forui/lib/src/theme/color_scheme.dart @@ -8,6 +8,9 @@ import 'package:forui/forui.dart'; /// See the pre-defined themes' color schemes in [FThemes]. final class FColorScheme with Diagnosticable { + /// The system brightness. + final Brightness brightness; + /// The background color. final Color background; @@ -43,6 +46,7 @@ final class FColorScheme with Diagnosticable { /// Creates a [FColorScheme]. const FColorScheme({ + required this.brightness, required this.background, required this.foreground, required this.primary, @@ -58,6 +62,7 @@ final class FColorScheme with Diagnosticable { /// Creates a copy of this [FColorScheme] with the given properties replaced. FColorScheme copyWith({ + Brightness? brightness, Color? background, Color? foreground, Color? primary, @@ -71,6 +76,7 @@ final class FColorScheme with Diagnosticable { Color? border, }) => FColorScheme( + brightness: brightness ?? this.brightness, background: background ?? this.background, foreground: foreground ?? this.foreground, primary: primary ?? this.primary, @@ -88,6 +94,7 @@ final class FColorScheme with Diagnosticable { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties + ..add(EnumProperty('brightness', brightness)) ..add(ColorProperty('background', background)) ..add(ColorProperty('foreground', foreground)) ..add(ColorProperty('primary', primary)) @@ -103,6 +110,7 @@ final class FColorScheme with Diagnosticable { @override bool operator ==(Object other) => identical(this, other) || other is FColorScheme && + brightness == other.brightness && background == other.background && foreground == other.foreground && primary == other.primary && @@ -117,6 +125,7 @@ final class FColorScheme with Diagnosticable { @override int get hashCode => + brightness.hashCode ^ background.hashCode ^ foreground.hashCode ^ primary.hashCode ^ diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart index 24b842e34..b123de18f 100644 --- a/forui/lib/src/theme/theme_data.dart +++ b/forui/lib/src/theme/theme_data.dart @@ -34,6 +34,9 @@ class FThemeData with Diagnosticable { /// The switch style. final FSwitchStyle switchStyle; + /// The text field style. + final FTextFieldStyle textFieldStyle; + /// Creates a [FThemeData]. FThemeData({ required this.colorScheme, @@ -46,6 +49,7 @@ class FThemeData with Diagnosticable { required this.boxStyle, required this.separatorStyles, required this.switchStyle, + required this.textFieldStyle, }); /// Creates a [FThemeData] that inherits the given properties. @@ -63,7 +67,8 @@ class FThemeData with Diagnosticable { headerStyle = FHeaderStyle.inherit(colorScheme: colorScheme, font: font), boxStyle = FBoxStyle.inherit(colorScheme: colorScheme), separatorStyles = FSeparatorStyles.inherit(colorScheme: colorScheme, style: style), - switchStyle = FSwitchStyle.inherit(colorScheme: colorScheme); + switchStyle = FSwitchStyle.inherit(colorScheme: colorScheme), + textFieldStyle = FTextFieldStyle.inherit(colorScheme: colorScheme, font: font, style: style); /// Creates a copy of this [FThemeData] with the given properties replaced. FThemeData copyWith({ @@ -77,6 +82,7 @@ class FThemeData with Diagnosticable { FBoxStyle? boxStyle, FSeparatorStyles? separatorStyles, FSwitchStyle? switchStyle, + FTextFieldStyle? textFieldStyle, }) => FThemeData( colorScheme: colorScheme ?? this.colorScheme, @@ -89,22 +95,24 @@ class FThemeData with Diagnosticable { boxStyle: boxStyle ?? this.boxStyle, separatorStyles: separatorStyles ?? this.separatorStyles, switchStyle: switchStyle ?? this.switchStyle, + textFieldStyle: textFieldStyle ?? this.textFieldStyle, ); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(DiagnosticsProperty('colorScheme', colorScheme, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('font', font, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('style', style, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('badgeStyles', badgeStyles, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('buttonStyles', buttonStyles, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('cardStyle', cardStyle, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('headerStyle', headerStyle, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('boxStyle', boxStyle, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('separatorStyles', separatorStyles, level: DiagnosticLevel.debug)) - ..add(DiagnosticsProperty('switchStyle', switchStyle)); + ..add(DiagnosticsProperty('colorScheme', colorScheme, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('font', font, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('style', style, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('badgeStyles', badgeStyles, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('buttonStyles', buttonStyles, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('cardStyle', cardStyle, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('headerStyle', headerStyle, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('boxStyle', boxStyle, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('separatorStyles', separatorStyles, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('switchStyle', switchStyle, level: DiagnosticLevel.debug)) + ..add(DiagnosticsProperty('textFieldStyle', textFieldStyle, level: DiagnosticLevel.debug)); } @override @@ -121,7 +129,8 @@ class FThemeData with Diagnosticable { headerStyle == other.headerStyle && boxStyle == other.boxStyle && separatorStyles == other.separatorStyles && - switchStyle == other.switchStyle; + switchStyle == other.switchStyle && + textFieldStyle == other.textFieldStyle; @override int get hashCode => @@ -134,5 +143,6 @@ class FThemeData with Diagnosticable { headerStyle.hashCode ^ boxStyle.hashCode ^ separatorStyles.hashCode ^ - switchStyle.hashCode; + switchStyle.hashCode ^ + textFieldStyle.hashCode; } diff --git a/forui/lib/src/theme/themes.dart b/forui/lib/src/theme/themes.dart index 5930c9901..7f02d79cc 100644 --- a/forui/lib/src/theme/themes.dart +++ b/forui/lib/src/theme/themes.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:forui/forui.dart'; @@ -9,6 +10,7 @@ extension FThemes on Never { light: FThemeData.inherit( font: FFont(), colorScheme: const FColorScheme( + brightness: Brightness.light, background: Color(0xFFFFFFFF), foreground: Color(0xFF09090B), primary: Color(0xFF18181B), @@ -26,6 +28,7 @@ extension FThemes on Never { dark: FThemeData.inherit( font: FFont(), colorScheme: const FColorScheme( + brightness: Brightness.dark, background: Color(0xFF09090B), foreground: Color(0xFFFAFAFA), primary: Color(0xFFFAFAFA), diff --git a/forui/lib/src/widgets/card/card.dart b/forui/lib/src/widgets/card/card.dart index 8f55fcc16..9321708b2 100644 --- a/forui/lib/src/widgets/card/card.dart +++ b/forui/lib/src/widgets/card/card.dart @@ -9,7 +9,7 @@ part 'card_content.dart'; /// A card widget. final class FCard extends StatelessWidget { - /// The style. + /// The style. Defaults to [FThemeData.cardStyle]. final FCardStyle? style; /// The child. diff --git a/forui/lib/src/widgets/text_field/text_field.dart b/forui/lib/src/widgets/text_field/text_field.dart new file mode 100644 index 000000000..82011b8db --- /dev/null +++ b/forui/lib/src/widgets/text_field/text_field.dart @@ -0,0 +1,516 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +part 'text_field_state.dart'; +part 'text_field_style.dart'; + +/// A text field. +final class FTextField extends StatefulWidget { + /// The style. + final FTextFieldStyle? style; + + /// The text to display when the text field is empty. + final String? hintText; + + /// The configuration for the magnifier of this text field. + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] on Android, and builds nothing on all + /// other platforms. To suppress the magnifier, consider passing [TextMagnifierConfiguration.disabled]. + final TextMagnifierConfiguration? magnifierConfiguration; + + /// Controls the text being edited. If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// Defines the keyboard focus for this widget. + /// + /// See [TextField.focusNode] for more information. + final FocusNode? focusNode; + + /// The type of keyboard to use for editing the text. Defaults to [TextInputType.text] if maxLines is one and + /// [TextInputType.multiline] otherwise. + final TextInputType? keyboardType; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.newline] if [keyboardType] is [TextInputType.multiline] and [TextInputAction.done] + /// otherwise. + final TextInputAction? textInputAction; + + /// Configures how the platform keyboard will select an uppercase or lowercase keyboard. Defaults to + /// [TextCapitalization.none]. + /// + /// Only supports text keyboards, other keyboard types will ignore this configuration. Capitalization is locale-aware. + /// + /// See [TextCapitalization] for a description of each capitalization behavior. + final TextCapitalization textCapitalization; + + /// How the text should be aligned horizontally. + /// + /// Defaults to [TextAlign.start]. + final TextAlign textAlign; + + /// How the text should be aligned vertically. + /// + /// See [TextAlignVertical] for more information. + final TextAlignVertical? textAlignVertical; + + /// The directionality of the text. Defaults to the ambient [Directionality], if any. + /// + /// See [TextField.textDirection] for more information. + final TextDirection? textDirection; + + /// Whether this text field should focus itself if nothing else is already focused. Defaults to false. + /// + /// If true, the keyboard will open as soon as this text field obtains focus. Otherwise, the keyboard is only shown + /// after the user taps the text field. + final bool autofocus; + + /// Represents the interactive "state" of this widget in terms of a set of [WidgetState]s, including + /// [WidgetState.disabled], [WidgetState.hovered], [WidgetState.error], and [WidgetState.focused]. + /// + /// See [TextField.statesController] for more information. + final WidgetStatesController? statesController; + + /// Whether to hide the text being edited (e.g., for passwords). Defaults to false. + /// + /// When this is set to true, all the characters in the text field are obscured, and the text in the field cannot be + /// copied with copy or cut. If [readOnly] is also true, then the text cannot be selected. + final bool obscureText; + + /// Whether to enable autocorrection. Defaults to true. + final bool autocorrect; + + /// Whether to allow the platform to automatically format dashes. + /// + /// See [TextField.smartDashesType] for more information. + final SmartDashesType? smartDashesType; + + /// Whether to allow the platform to automatically format quotes. + /// + /// See [TextField.smartQuotesType] for more information. + final SmartQuotesType? smartQuotesType; + + /// Whether to show input suggestions as the user types. Defaults to true. + /// + /// This flag only affects Android. On iOS, suggestions are tied directly to [autocorrect], so that suggestions are + /// only shown when [autocorrect] is true. On Android autocorrection and suggestion are controlled separately. + /// + /// See also: + /// * + final bool enableSuggestions; + + /// The minimum number of lines to occupy when the content spans fewer lines. + /// + /// This affects the height of the field itself and does not limit the number of lines that can be entered into the field. + /// + /// If this is null (default), text container starts with enough vertical space for one line and grows to accommodate + /// additional lines as they are entered. + /// + /// This can be used in combination with [maxLines] for a varying set of behaviors. + /// + /// If the value is set, it must be greater than zero. If the value is greater than 1, [maxLines] should also be set + /// to either null or greater than this value. + /// + /// When [maxLines] is set as well, the height will grow between the indicated range of lines. When [maxLines] is null, + /// it will grow as high as needed, starting from [minLines]. + /// + /// A few examples of behaviors possible with [minLines] and [maxLines] are as follows. These apply equally to + /// [TextField], [TextFormField], [CupertinoTextField], and [EditableText]. + /// + /// Input that always occupies at least 2 lines and has an infinite max. Expands vertically as needed. + /// ```dart + /// TextField(minLines: 2) + /// ``` + /// + /// Input whose height starts from 2 lines and grows up to 4 lines at which point the height limit is reached. + /// If additional lines are entered it will scroll vertically. + /// ```dart + /// const TextField(minLines:2, maxLines: 4) + /// ``` + /// + /// Defaults to null. + /// + /// See also: + /// * [maxLines], which sets the maximum number of lines visible, and has several examples of how minLines and + /// maxLines interact to produce various behaviors. + /// * [expands], which determines whether the field should fill the height of its parent. + final int? minLines; + + /// The maximum number of lines to show at one time, wrapping if necessary. + /// + /// This affects the height of the field itself and does not limit the number of lines that can be entered into the + /// field. + /// + /// If this is 1 (the default), the text will not wrap, but will scroll horizontally instead. + /// + /// If this is null, there is no limit to the number of lines, and the text container will start with enough vertical + /// space for one line and automatically grow to accommodate additional lines as they are entered, up to the height of + /// its constraints. + /// + /// If this is not null, the value must be greater than zero, and it will lock the input to the given number of lines + /// and take up enough horizontal space to accommodate that number of lines. Setting [minLines] as well allows the + /// input to grow and shrink between the indicated range. + /// + /// The full set of behaviors possible with [minLines] and [maxLines] are as follows. These examples apply equally to + /// [TextField], [TextFormField], [CupertinoTextField], and [EditableText]. + /// + /// Input that occupies a single line and scrolls horizontally as needed. + /// ```dart + /// const TextField() + /// ``` + /// + /// Input whose height grows from one line up to as many lines as needed for the text that was entered. If a height + /// limit is imposed by its parent, it will scroll vertically when its height reaches that limit. + /// ```dart + /// const TextField(maxLines: null) + /// ``` + /// + /// The input's height is large enough for the given number of lines. If additional lines are entered the input scrolls + /// vertically. + /// ```dart + /// const TextField(maxLines: 2) + /// ``` + /// + /// Input whose height grows with content between a min and max. An infinite max is possible with `maxLines: null`. + /// ```dart + /// const TextField(minLines: 2, maxLines: 4) + /// ``` + /// + /// See also: + /// * [minLines], which sets the minimum number of lines visible. + /// * [expands], which determines whether the field should fill the height of its parent. + final int? maxLines; + + /// Whether this widget's height will be sized to fill its parent. Defaults to false. + /// + /// If set to true and wrapped in a parent widget like [Expanded] or [SizedBox], the input will expand to fill the + /// parent. + /// + /// [maxLines] and [minLines] must both be null when this is set to true, otherwise an error is thrown. + /// + /// See the examples in [maxLines] for the complete picture of how [maxLines], [minLines], and [expands] interact to + /// produce various behaviors. + /// + /// Input that matches the height of its parent: + /// ```dart + /// const Expanded( + /// child: FTextField(maxLines: null, expands: true), + /// ) + /// ``` + final bool expands; + + /// Whether the text can be changed. Defaults to false. + /// + /// When this is set to true, the text cannot be modified by any shortcut or keyboard operation. The text is still + /// selectable. + final bool readOnly; + + /// Whether to show cursor. + /// + /// The cursor refers to the blinking caret when this [FTextField] is focused. + final bool? showCursor; + + /// The maximum number of characters (Unicode grapheme clusters) to allow in the text field. + /// + /// If set, a character counter will be displayed below the field showing how many characters have been entered. If + /// set to a number greater than 0, it will also display the maximum number allowed. If set to [TextField.noMaxLength] + /// then only the current character count is displayed. + /// + /// After [maxLength] characters have been input, additional input is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// + /// The text field enforces the length with a [LengthLimitingTextInputFormatter], which is evaluated after the supplied + /// [inputFormatters], if any. + /// + /// This value must be either null, [TextField.noMaxLength], or greater than 0. If null (the default) then there is no + /// limit to the number of characters that can be entered. If set to [TextField.noMaxLength], then no limit will be + /// enforced, but the number of characters entered will still be displayed. + /// + /// Whitespace characters (e.g. newline, space, tab) are included in the character count. + /// + /// If [maxLengthEnforcement] is [MaxLengthEnforcement.none], then more than [maxLength] characters may be entered, + /// but the error counter and divider will switch to the [style]'s [FTextFieldStyle.error] when the limit is exceeded. + final int? maxLength; + + /// Determines how the [maxLength] limit should be enforced. + final MaxLengthEnforcement? maxLengthEnforcement; + + /// Called when the user initiates a change to the TextField's value: when they have inserted or deleted text. + /// + /// This callback doesn't run when the TextField's text is changed programmatically, via the TextField's [controller]. + /// Typically it isn't necessary to be notified of such changes, since they're initiated by the app itself. + /// + /// To be notified of all changes to the TextField's text, cursor, and selection, one can add a listener to its + /// [controller] with [TextEditingController.addListener]. + /// + /// [onChange] is called before [onSubmit] when user indicates completion of editing, such as when pressing the "done" + /// button on the keyboard. That default behavior can be overridden. See [onEditingComplete] for details. + /// + /// See also: + /// * [inputFormatters], which are called before [onChange] runs and can validate and change ("format") the input value. + /// * [onEditingComplete], [onSubmit]: which are more specialized input change notifications. + final ValueChanged? onChange; + + /// Called when the user submits editable content (e.g., user presses the "done" button on the keyboard). + /// + /// The default implementation of [onEditingComplete] executes 2 different behaviors based on the situation: + /// + /// - When a completion action is pressed, such as "done", "go", "send", or "search", the user's content is submitted + /// to the [controller] and then focus is given up. + /// + /// - When a non-completion action is pressed, such as "next" or "previous", the user's content is submitted to the + /// [controller], but focus is not given up because developers may want to immediately move focus to another input + /// widget within [onSubmit]. + /// + /// Providing [onEditingComplete] prevents the aforementioned default behavior. + final VoidCallback? onEditingComplete; + + /// Called when the user indicates that they are done editing the text in the field. + /// + /// By default, [onSubmit] is called after [onChange] when the user has finalized editing; or, if the default behavior + /// has been overridden, after [onEditingComplete]. See [onEditingComplete] for details. + /// + /// ## Testing + /// The following is the recommended way to trigger [onSubmit] in a test: + /// + /// ```dart + /// await tester.testTextInput.receiveAction(TextInputAction.done); + /// ``` + /// + /// Sending a `LogicalKeyboardKey.enter` via `tester.sendKeyEvent` will not trigger [onSubmit]. This is because on a + /// real device, the engine translates the enter key to a done action, but `tester.sendKeyEvent` sends the key to the + /// framework only. + final ValueChanged? onSubmit; + + /// This is used to receive a private command from the input method. + /// + /// Called when the result of [TextInputClient.performPrivateCommand] is received. + /// + /// This can be used to provide domain-specific features that are only known between certain input methods and their + /// clients. + /// + /// See also: + /// * [performPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputConnection#performPrivateCommand\(java.lang.String,%20android.os.Bundle\)), + /// which is the Android documentation for performPrivateCommand, used to send a command from the input method. + /// * [sendAppPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#sendAppPrivateCommand), + /// which is the Android documentation for sendAppPrivateCommand, used to send a command to the input method. + final AppPrivateCommandCallback? onAppPrivateCommand; + + /// Optional input validation and formatting overrides. + /// + /// Formatters are run in the provided order when the user changes the text this widget contains. When this parameter + /// changes, the new formatters will not be applied until the next time the user inserts or deletes text. Similar to + /// the [onChange] callback, formatters don't run when the text is changed programmatically via [controller]. + /// + /// See also: + /// * [TextEditingController], which implements the [Listenable] interface and notifies its listeners on + /// [TextEditingValue] changes. + final List? inputFormatters; + + /// If false the text field is "disabled": it ignores taps. Defaults to true. + final bool enabled; + + /// Determines whether this widget ignores pointer events. Defaults to null, and when null, does nothing. + final bool? ignorePointers; + + /// Whether to enable user interface affordances for changing the text selection. Defaults to true. + /// + /// For example, setting this to true will enable features such as long-pressing the TextField to select text and show + /// the cut/copy/paste menu, and tapping to move the text caret. + /// + /// When this is false, the text selection cannot be adjusted by the user, text cannot be copied, and the user cannot + /// paste into the text field from the clipboard. + final bool enableInteractSelection; + + /// Optional delegate for building the text selection handles. + /// + /// Historically, this field also controlled the toolbar. This is now handled by [contextMenuBuilder] instead. However, + /// for backwards compatibility, when [selectionControls] is set to an object that does not mix in + // ignore: deprecated_member_use + /// [TextSelectionHandleControls], [contextMenuBuilder] is ignored and the [TextSelectionControls.buildToolbar] method + /// is used instead. + final TextSelectionControls? selectionControls; + + /// Determines the way that drag start behavior is handled. By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// If set to [DragStartBehavior.start], scrolling drag behavior will begin at the position where the drag gesture won + /// the arena. If set to [DragStartBehavior.down] it will begin at the position where a down event is first detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag animation smoother and setting it to + /// [DragStartBehavior.down] will make drag behavior feel slightly more reactive. + /// + /// See also: + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors. + final DragStartBehavior dragStartBehavior; + + // TODO: MouseCursor? mouseCursor; + + // TODO: InputCounterWidgetBuilder? buildCounter; + + /// The [ScrollPhysics] to use when vertically scrolling the input. If not specified, it will behave according to the + /// current platform. + /// + /// See [Scrollable.physics]. + final ScrollPhysics? scrollPhysics; + + /// The [ScrollController] to use when vertically scrolling the input. If null, it will instantiate a new ScrollController. + /// + /// See [Scrollable.controller]. + final ScrollController? scrollController; + + /// A list of strings that helps the autofill service identify the type of this text input. + /// + /// See [TextField.autofillHints] for more information. + final Iterable? autofillHints; + + /// Restoration ID to save and restore the state of the text field. + /// + /// See [TextField.restorationId] for more information. + final String? restorationId; + + /// Whether iOS 14 Scribble features are enabled for this widget. Defaults to true. + /// + /// Only available on iPads. + final bool scribbleEnabled; + + /// Whether to enable that the IME update personalized data such as typing history and user dictionary data. + /// + /// See [TextField.enableIMEPersonalizedLearning] for more information. + final bool enableIMEPersonalizedLearning; + + // TODO: ContentInsertionConfiguration? contentInsertionConfiguration + + /// Builds the text selection toolbar when requested by the user. + /// + /// See [TextField.contextMenuBuilder] for more information. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + /// Determine whether this text field can request the primary focus. + /// + /// Defaults to true. If false, the text field will not request focus when tapped, or when its context menu is + /// displayed. If false it will not be possible to move the focus to the text field with tab key. + final bool canRequestFocus; + + /// Controls the undo state. + /// + /// If null, this widget will create its own [UndoHistoryController]. + final UndoHistoryController? undoController; + + /// Configuration that details how spell check should be performed. + /// + /// Specifies the [SpellCheckService] used to spell check text input and the [TextStyle] used to style text with + /// misspelled words. + /// + /// If the [SpellCheckService] is left null, spell check is disabled by default unless the [DefaultSpellCheckService] + /// is supported, in which case it is used. It is currently supported only on Android and iOS. + /// + /// If this configuration is left null, then spell check is disabled by default. + final SpellCheckConfiguration? spellCheckConfiguration; + + /// Creates an [FTextField]. + const FTextField({ + this.style, + this.hintText, + this.magnifierConfiguration, + this.controller, + this.focusNode, + this.keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.autofocus = false, + this.statesController, + this.obscureText = false, + this.autocorrect = true, + this.smartDashesType, + this.smartQuotesType, + this.enableSuggestions = true, + this.minLines, + this.maxLines, + this.expands = false, + this.readOnly = false, + this.showCursor, + this.maxLength, + this.maxLengthEnforcement, + this.onChange, + this.onEditingComplete, + this.onSubmit, + this.onAppPrivateCommand, + this.inputFormatters, + this.enabled = true, + this.ignorePointers, + this.enableInteractSelection = true, + this.selectionControls, + this.dragStartBehavior = DragStartBehavior.start, + this.scrollPhysics, + this.scrollController, + this.autofillHints, + this.restorationId, + this.scribbleEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, + this.canRequestFocus = true, + this.undoController, + this.spellCheckConfiguration, + }); + + @override + State createState() => _State(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('style', style)) + ..add(StringProperty('hintText', hintText)) + ..add(DiagnosticsProperty('magnifierConfiguration', magnifierConfiguration)) + ..add(DiagnosticsProperty('controller', controller)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(DiagnosticsProperty('keyboardType', keyboardType)) + ..add(EnumProperty('textInputAction', textInputAction)) + ..add(EnumProperty('textCapitalization', textCapitalization)) + ..add(EnumProperty('textAlign', textAlign)) + ..add(DiagnosticsProperty('textAlignVertical', textAlignVertical)) + ..add(EnumProperty('textDirection', textDirection)) + ..add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus')) + ..add(DiagnosticsProperty('statesController', statesController)) + ..add(FlagProperty('obscureText', value: obscureText, ifTrue: 'obscureText')) + ..add(FlagProperty('autocorrect', value: autocorrect, ifTrue: 'autocorrect')) + ..add(EnumProperty('smartDashesType', smartDashesType)) + ..add(EnumProperty('smartQuotesType', smartQuotesType)) + ..add(FlagProperty('enableSuggestions', value: enableSuggestions, ifTrue: 'enableSuggestions')) + ..add(IntProperty('minLines', minLines)) + ..add(IntProperty('maxLines', maxLines)) + ..add(FlagProperty('expands', value: expands, ifTrue: 'expands')) + ..add(FlagProperty('readOnly', value: readOnly, ifTrue: 'readOnly')) + ..add(FlagProperty('showCursor', value: showCursor, ifTrue: 'showCursor')) + ..add(IntProperty('maxLength', maxLength)) + ..add(EnumProperty('maxLengthEnforcement', maxLengthEnforcement)) + ..add(DiagnosticsProperty('onChange', onChange)) + ..add(DiagnosticsProperty('onEditingComplete', onEditingComplete)) + ..add(DiagnosticsProperty('onSubmit', onSubmit)) + ..add(DiagnosticsProperty('onAppPrivateCommand', onAppPrivateCommand)) + ..add(IterableProperty('inputFormatters', inputFormatters)) + ..add(FlagProperty('enabled', value: enabled, ifTrue: 'enabled')) + ..add(FlagProperty('ignorePointers', value: ignorePointers, ifTrue: 'ignorePointers')) + ..add(FlagProperty('enableInteractSelection', value: enableInteractSelection, ifTrue: 'enableInteractSelection')) + ..add(DiagnosticsProperty('selectionControls', selectionControls)) + ..add(EnumProperty('dragStartBehavior', dragStartBehavior)) + ..add(DiagnosticsProperty('scrollPhysics', scrollPhysics)) + ..add(DiagnosticsProperty('scrollController', scrollController)) + ..add(IterableProperty('autofillHints', autofillHints)) + ..add(StringProperty('restorationId', restorationId)) + ..add(FlagProperty('scribbleEnabled', value: scribbleEnabled, ifTrue: 'scribbleEnabled')) + ..add(FlagProperty('enableIMEPersonalizedLearning', value: enableIMEPersonalizedLearning, ifTrue: 'enableIMEPersonalizedLearning')) + ..add(DiagnosticsProperty('contextMenuBuilder', contextMenuBuilder)) + ..add(FlagProperty('canRequestFocus', value: canRequestFocus, ifTrue: 'canRequestFocus')) + ..add(DiagnosticsProperty('undoController', undoController)) + ..add(DiagnosticsProperty('spellCheckConfiguration', spellCheckConfiguration)); + } +} diff --git a/forui/lib/src/widgets/text_field/text_field_state.dart b/forui/lib/src/widgets/text_field/text_field_state.dart new file mode 100644 index 000000000..77e971ad5 --- /dev/null +++ b/forui/lib/src/widgets/text_field/text_field_state.dart @@ -0,0 +1,135 @@ +part of 'text_field.dart'; + +class _State extends State { + + late final WidgetStatesController controller; + + @override + void initState() { + super.initState(); + controller = WidgetStatesController(); + } + + @override + Widget build(BuildContext context) { + final theme = context.theme; + final style = widget.style ?? theme.textFieldStyle; + + return Theme( + // The selection colors are defined in a Theme instead of TextField since TextField does not expose parameters + // for overriding selectionHandleColor. + data: Theme.of(context).copyWith( + textSelectionTheme: TextSelectionThemeData( + cursorColor: style.cursor, + selectionColor: style.cursor.withOpacity(0.4), + selectionHandleColor: style.cursor, + ), + cupertinoOverrideTheme: CupertinoThemeData( + primaryColor: style.cursor, + ), + ), + // This is done because InputDecoration.errorBorder and InputDecoration.focusedErrorBorder aren't shown unless an + // additional error help text is supplied. That error help text has few configuration options. + child: ValueListenableBuilder( + valueListenable: widget.statesController ?? controller, + builder: (context, states, _) { + final (enabled, focus) = states.contains(WidgetState.error) ? (style.error, style.focusedError) : (style.enabled, style.focused); + final current = states.contains(WidgetState.focused) ? focus : enabled; + return _build(context, style, current, enabled, focus); + }, + ), + ); + } + + Widget _build( + BuildContext context, + FTextFieldStyle style, + FTextFieldStateStyle current, + FTextFieldStateStyle enabled, + FTextFieldStateStyle focused, + ) => TextField( + controller: widget.controller, + focusNode: widget.focusNode, + undoController: widget.undoController, + decoration: InputDecoration( + contentPadding: style.contentPadding, + hintText: widget.hintText, + hintStyle: current.hint, + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: enabled.borderColor, + width: enabled.borderWidth, + ), + borderRadius: enabled.borderRadius, + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: style.disabled.borderColor, + width: style.disabled.borderWidth, + ), + borderRadius: style.disabled.borderRadius, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: focused.borderColor, + width: focused.borderWidth, + ), + borderRadius: enabled.borderRadius, + ), + ), + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: current.content, + textAlign: widget.textAlign, + textAlignVertical: widget.textAlignVertical, + textDirection: widget.textDirection, + readOnly: widget.readOnly, + showCursor: widget.showCursor, + autofocus: widget.autofocus, + statesController: widget.statesController ?? controller, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + maxLength: widget.maxLength, + maxLengthEnforcement: widget.maxLengthEnforcement, + onChanged: widget.onChange, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmit, + onAppPrivateCommand: widget.onAppPrivateCommand, + inputFormatters: widget.inputFormatters, + enabled: widget.enabled, + ignorePointers: widget.ignorePointers, + keyboardAppearance: style.keyboardAppearance, + scrollPadding: style.scrollPadding, + dragStartBehavior: widget.dragStartBehavior, + selectionControls: widget.selectionControls, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + autofillHints: widget.autofillHints, + restorationId: widget.restorationId, + scribbleEnabled: widget.scribbleEnabled, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + contextMenuBuilder: widget.contextMenuBuilder, + canRequestFocus: widget.canRequestFocus, + spellCheckConfiguration: widget.spellCheckConfiguration, + magnifierConfiguration: widget.magnifierConfiguration, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('controller', controller)); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } +} diff --git a/forui/lib/src/widgets/text_field/text_field_style.dart b/forui/lib/src/widgets/text_field/text_field_style.dart new file mode 100644 index 000000000..087724287 --- /dev/null +++ b/forui/lib/src/widgets/text_field/text_field_style.dart @@ -0,0 +1,221 @@ +part of 'text_field.dart'; + +/// A [FTextFieldStyle]'s style. +final class FTextFieldStyle with Diagnosticable { + /// The appearance of the keyboard. Defaults to [FColorScheme.brightness]. + /// + /// This setting is only honored on iOS devices. + final Brightness keyboardAppearance; + + /// The color of the cursor. Defaults to [CupertinoColors.activeBlue]. + /// + /// The cursor indicates the current location of text insertion point in the field. + final Color cursor; + + /// The padding surrounding this text field's content. Defaults to `const EdgeInsets.symmetric(horizontal: 16)`. + final EdgeInsets contentPadding; + + /// Configures padding to edges surrounding a [Scrollable] when this text field scrolls into view. + /// + /// Defaults to `EdgeInsets.all(20.0)`. + /// + /// When this widget receives focus and is not completely visible (for example scrolled partially off the screen or + /// overlapped by the keyboard) then it will attempt to make itself visible by scrolling a surrounding [Scrollable], + /// if one is present. This value controls how far from the edges of a [Scrollable] the TextField will be positioned + /// after the scroll. + final EdgeInsets scrollPadding; + + /// The style when this text field is enabled. + final FTextFieldStateStyle enabled; + + /// The style when this text field is enabled. + final FTextFieldStateStyle disabled; + + /// The style when this text field has an error. + final FTextFieldStateStyle error; + + /// The style when this text field is focused. + final FTextFieldStateStyle focused; + + /// The style when this text field is focused and has an error. + final FTextFieldStateStyle focusedError; + + /// Creates a [FTextFieldStyle]. + FTextFieldStyle({ + required this.keyboardAppearance, + required this.enabled, + required this.disabled, + required this.error, + required this.focused, + required this.focusedError, + this.cursor = CupertinoColors.activeBlue, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 16), + this.scrollPadding = const EdgeInsets.all(20.0), + }); + + /// Creates a [FTextFieldStyle] that inherits its properties. + FTextFieldStyle.inherit({ + required FColorScheme colorScheme, + required FFont font, + required FStyle style, + }): + keyboardAppearance = colorScheme.brightness, + cursor = CupertinoColors.activeBlue, + contentPadding = const EdgeInsets.symmetric(horizontal: 16), + scrollPadding = const EdgeInsets.all(20.0), + enabled = FTextFieldStateStyle.inherit( + contentColor: colorScheme.primary, + hintColor: colorScheme.mutedForeground, + borderColor: colorScheme.border, + font: font, + style: style, + ), + disabled = FTextFieldStateStyle.inherit( + contentColor: colorScheme.primary, + hintColor: colorScheme.border.withOpacity(0.5), + borderColor: colorScheme.border.withOpacity(0.5), + font: font, + style: style, + ), + error = FTextFieldStateStyle.inherit( // TODO: add error colors + contentColor: colorScheme.primary, + hintColor: colorScheme.mutedForeground, + borderColor: colorScheme.border, + font: font, + style: style, + ), + focused = FTextFieldStateStyle.inherit( + contentColor: colorScheme.primary, + hintColor: colorScheme.mutedForeground, + borderColor: colorScheme.primary, + font: font, + style: style, + ), + focusedError = FTextFieldStateStyle.inherit( // TODO: add error colors + contentColor: colorScheme.primary, + hintColor: colorScheme.mutedForeground, + borderColor: colorScheme.primary, + font: font, + style: style, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('keyboardAppearance', keyboardAppearance)) + ..add(ColorProperty('cursor', cursor, defaultValue: CupertinoColors.activeBlue)) + ..add(DiagnosticsProperty('contentPadding', contentPadding)) + ..add(DiagnosticsProperty('scrollPadding', scrollPadding)) + ..add(DiagnosticsProperty('enabledBorder', enabled)) + ..add(DiagnosticsProperty('disabledBorder', disabled)) + ..add(DiagnosticsProperty('errorBorder', error)) + ..add(DiagnosticsProperty('focusedBorder', focused)) + ..add(DiagnosticsProperty('focusedErrorBorder', focusedError)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FTextFieldStyle && + runtimeType == other.runtimeType && + keyboardAppearance == other.keyboardAppearance && + cursor == other.cursor && + contentPadding == other.contentPadding && + scrollPadding == other.scrollPadding && + enabled == other.enabled && + disabled == other.disabled && + error == other.error && + focused == other.focused && + focusedError == other.focusedError; + + @override + int get hashCode => + keyboardAppearance.hashCode ^ + cursor.hashCode ^ + contentPadding.hashCode ^ + scrollPadding.hashCode ^ + enabled.hashCode ^ + disabled.hashCode ^ + error.hashCode ^ + focused.hashCode ^ + focusedError.hashCode; +} + +/// A [FTextFieldStateStyle]'s style. +final class FTextFieldStateStyle with Diagnosticable { + /// The content's [TextStyle]. + final TextStyle content; + + /// The hint's [TextStyle]. + final TextStyle hint; + + /// The border's color. + final Color borderColor; + + /// The border's width. Defaults to [FStyle.borderWidth]. + final double borderWidth; + + /// The border's width. Defaults to [FStyle.borderRadius]. + final BorderRadius borderRadius; + + /// Creates a [FTextFieldStateStyle]. + FTextFieldStateStyle({ + required this.content, + required this.hint, + required this.borderColor, + required this.borderWidth, + required this.borderRadius, + }); + + /// Creates a [FTextFieldStateStyle] that inherits its properties. + FTextFieldStateStyle.inherit({ + required Color contentColor, + required Color hintColor, + required this.borderColor, + required FFont font, + required FStyle style, + }): + content = TextStyle( + fontFamily: font.family, + fontSize: font.sm, + color: contentColor, + ), + hint = TextStyle( + fontFamily: font.family, + fontSize: font.sm, + color: hintColor, + ), + borderWidth = style.borderWidth, + borderRadius = style.borderRadius; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('content', content)) + ..add(DiagnosticsProperty('hint', hint)) + ..add(ColorProperty('borderColor', borderColor)) + ..add(DoubleProperty('borderWidth', borderWidth)) + ..add(DiagnosticsProperty('borderRadius', borderRadius)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FTextFieldStateStyle && + runtimeType == other.runtimeType && + content == other.content && + hint == other.hint && + borderColor == other.borderColor && + borderWidth == other.borderWidth && + borderRadius == other.borderRadius; + + @override + int get hashCode => + content.hashCode ^ + hint.hashCode ^ + borderColor.hashCode ^ + borderWidth.hashCode ^ + borderRadius.hashCode; +} diff --git a/forui/test/src/theme/color_scheme_test.dart b/forui/test/src/theme/color_scheme_test.dart index eeae17829..9f5a683aa 100644 --- a/forui/test/src/theme/color_scheme_test.dart +++ b/forui/test/src/theme/color_scheme_test.dart @@ -8,6 +8,7 @@ import 'package:forui/forui.dart'; void main() { group('FColorScheme', () { const scheme = FColorScheme( + brightness: Brightness.light, background: Colors.black, foreground: Colors.black12, primary: Colors.black26, @@ -26,6 +27,7 @@ void main() { test('all arguments', () { final copy = scheme.copyWith( + brightness: Brightness.dark, background: Colors.red, foreground: Colors.greenAccent, primary: Colors.yellow, @@ -39,6 +41,7 @@ void main() { border: Colors.lime, ); + expect(copy.brightness, equals(Brightness.dark)); expect(copy.background, equals(Colors.red)); expect(copy.foreground, equals(Colors.greenAccent)); expect(copy.primary, equals(Colors.yellow)); @@ -58,6 +61,7 @@ void main() { scheme.debugFillProperties(builder); expect(builder.properties.map((p) => p.toString()), [ + EnumProperty('brightness', Brightness.light), ColorProperty('background', Colors.black), ColorProperty('foreground', Colors.black12), ColorProperty('primary', Colors.black26),