diff --git a/docs/content/docs/form/otp-field.mdx b/docs/content/docs/form/otp-field.mdx
new file mode 100644
index 000000000..7d3c5e08c
--- /dev/null
+++ b/docs/content/docs/form/otp-field.mdx
@@ -0,0 +1,65 @@
+---
+title: OTP Field
+description: A one-time password input field for verification codes.
+apiReference: https://pub.dev/documentation/forui/latest/forui.widgets.otp_field/
+---
+
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
+import { Widget } from '@/components/demo/widget';
+import { CodeSnippet } from '@/components/code-snippet/code-snippet';
+import { UsageSnippet } from '@/components/usage-snippet/usage-snippet';
+import defaultSnippet from '@/snippets/examples/otp-field/default.json';
+import dividerSnippet from '@/snippets/examples/otp-field/divider.json';
+import noFormatterSnippet from '@/snippets/examples/otp-field/no-formatter.json';
+import otpFieldUsage from '@/snippets/usages/widgets/otp_field/otpField.json';
+
+
+
+
+
+
+
+
+
+
+## CLI
+
+To generate a specific style for customization:
+
+
+
+ ```shell copy
+ dart run forui style create otp-field
+ ```
+
+
+
+## Usage
+
+### `FOtpField(...)`
+
+
+## Examples
+
+### With Divider
+
+
+
+
+
+
+
+
+
+
+### No Formatter
+
+
+
+
+
+
+
+
+
+
diff --git a/docs_snippets/lib/examples/widgets/otp_field.dart b/docs_snippets/lib/examples/widgets/otp_field.dart
new file mode 100644
index 000000000..838a36cbe
--- /dev/null
+++ b/docs_snippets/lib/examples/widgets/otp_field.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/widgets.dart';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:forui/forui.dart';
+
+import 'package:docs_snippets/example.dart';
+
+@RoutePage()
+class OtpFieldPage extends Example {
+ OtpFieldPage({@queryParam super.theme});
+
+ @override
+ Widget example(BuildContext _) => FOtpField();
+}
+
+@RoutePage()
+class DividerOtpFieldPage extends Example {
+ DividerOtpFieldPage({@queryParam super.theme});
+
+ @override
+ Widget example(BuildContext _) => FOtpField(
+ // {@highlight}
+ control: const .managed(
+ children: [FOtpItem(), FOtpItem(), FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem(), FOtpItem()],
+ ),
+ // {@endhighlight}
+ );
+}
+
+@RoutePage()
+class NoFormatterOtpFieldPage extends Example {
+ NoFormatterOtpFieldPage({@queryParam super.theme});
+
+ @override
+ Widget example(BuildContext _) => FOtpField(
+ // {@highlight}
+ inputFormatters: const [],
+ // {@endhighlight}
+ );
+}
diff --git a/docs_snippets/lib/main.dart b/docs_snippets/lib/main.dart
index 0eed9578b..aab527e37 100644
--- a/docs_snippets/lib/main.dart
+++ b/docs_snippets/lib/main.dart
@@ -138,6 +138,9 @@ class _AppRouter extends RootStackRouter {
AutoRoute(path: '/multi-select/sorted', page: SortedMultiSelectRoute.page),
AutoRoute(path: '/multi-select/popover-builder', page: PopoverBuilderMultiSelectRoute.page),
AutoRoute(path: '/multi-select/form', page: FormMultiSelectRoute.page),
+ AutoRoute(path: '/otp-field/default', page: OtpFieldRoute.page),
+ AutoRoute(path: '/otp-field/divider', page: DividerOtpFieldRoute.page),
+ AutoRoute(path: '/otp-field/no-formatter', page: NoFormatterOtpFieldRoute.page),
AutoRoute(path: '/pagination/default', page: PaginationRoute.page),
AutoRoute(path: '/pagination/siblings', page: SiblingsPaginationRoute.page),
AutoRoute(path: '/pagination/hide-edges', page: HideEdgesPaginationRoute.page),
diff --git a/docs_snippets/lib/usages/widgets/otp_field.dart b/docs_snippets/lib/usages/widgets/otp_field.dart
new file mode 100644
index 000000000..ff2e83503
--- /dev/null
+++ b/docs_snippets/lib/usages/widgets/otp_field.dart
@@ -0,0 +1,75 @@
+// ignore_for_file: avoid_redundant_argument_values
+
+import 'package:flutter/widgets.dart';
+
+import 'package:forui/forui.dart';
+
+final otpField = FOtpField(
+ // {@category "Control"}
+ control: const .managed(),
+ // {@endcategory}
+ // {@category "Form"}
+ label: const Text('Verification Code'),
+ description: const Text('Enter the 6-digit code.'),
+ errorBuilder: FFormFieldProperties.defaultErrorBuilder,
+ forceErrorText: null,
+ onSaved: null,
+ onReset: null,
+ validator: null,
+ autovalidateMode: .disabled,
+ formFieldKey: null,
+ // {@endcategory}
+ // {@category "Field"}
+ builder: FOtpField.defaultBuilder,
+ magnifierConfiguration: null,
+ groupId: EditableText,
+ keyboardType: null,
+ textInputAction: .done,
+ textCapitalization: .none,
+ textDirection: null,
+ autofocus: false,
+ focusNode: null,
+ readOnly: false,
+ onTapAlwaysCalled: false,
+ onEditingComplete: () {},
+ onSubmit: (value) {},
+ onAppPrivateCommand: (action, data) {},
+ onTap: () {},
+ onTapOutside: (event) {},
+ inputFormatters: FOtpField.defaultInputFormatters,
+ ignorePointers: null,
+ enableInteractiveSelection: true,
+ selectionControls: null,
+ dragStartBehavior: .start,
+ mouseCursor: null,
+ autofillHints: const [AutofillHints.oneTimeCode],
+ restorationId: null,
+ stylusHandwritingEnabled: true,
+ enableIMEPersonalizedLearning: true,
+ contentInsertionConfiguration: null,
+ contextMenuBuilder: null,
+ canRequestFocus: true,
+ undoController: null,
+ statesController: null,
+ // {@endcategory}
+ // {@category "Core"}
+ style: const .context(),
+ enabled: true,
+ // {@endcategory}
+);
+
+// {@category "Control" "`.managed()` with internal controller"}
+/// Manages the controller internally. Allows configuring children and initial value.
+final FOtpFieldControl managedInternal = .managed(
+ children: const [FOtpItem(), FOtpItem(), FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem(), FOtpItem()],
+ initial: null,
+ onChange: (value) {},
+);
+
+// {@category "Control" "`.managed()` with external controller"}
+/// Uses an external `FOtpController` to control the OTP field's state.
+final FOtpFieldControl managedExternal = .managed(
+ // Don't create a controller inline. Store it in a State instead.
+ controller: FOtpController(),
+ onChange: (value) {},
+);
diff --git a/docs_snippets/lib/usages/widgets/popover_menu.dart b/docs_snippets/lib/usages/widgets/popover_menu.dart
index 041259255..557111830 100644
--- a/docs_snippets/lib/usages/widgets/popover_menu.dart
+++ b/docs_snippets/lib/usages/widgets/popover_menu.dart
@@ -43,7 +43,6 @@ final popoverMenu = FPopoverMenu(
// {@category "Core"}
style: const .delta(maxWidth: 250),
divider: .full,
- hover: true,
menu: [
.group(
children: [
@@ -97,7 +96,6 @@ final popoverMenuTiles = FPopoverMenu.tiles(
// {@category "Core"}
style: const .delta(maxWidth: 250),
divider: .full,
- hover: true,
menu: [
.group(
children: [
diff --git a/forui/CHANGELOG.md b/forui/CHANGELOG.md
index 58f000212..96140be5f 100644
--- a/forui/CHANGELOG.md
+++ b/forui/CHANGELOG.md
@@ -17,12 +17,16 @@
### `FPopoverMenu`
* Add `FSubmenuItem`.
* Add `FSubmenuTile`.
-* Add `FPopoverMenu.hover`.
* Add `FPopoverMenuStyle.motion`.
* Add `FPopoverMenuMotion`.
* Add `FPopoverMenuStyle.minWidth`.
+### `FTextField`
+* Add `FTextFieldStyle.cursorWidth`.
+* Add `FTextFieldStyle.cursorOpacityAnimates`.
+
+
### `FTabs`
* Add swipe navigation when `expands` is true. When swipe navigation is enabled (i.e. `expands` is true and `swipeablePhysics` resolves to true), the content area's `physics` defaults to `BouncingScrollPhysics`.
* Add `FTabs.swipeablePhysics` to toggle swipe navigation independently from `scrollable`.
@@ -33,6 +37,10 @@
* Add `FTileMixin.submenu(...)` shorthand for `FSubmenuTile`.
+### `FOtpField`
+* Add `FOtpField`.
+
+
### `FOverlay`
* Add `FOverlay`.
diff --git a/forui/bin/commands/style/style.dart b/forui/bin/commands/style/style.dart
index 9bb281eec..29eecc597 100644
--- a/forui/bin/commands/style/style.dart
+++ b/forui/bin/commands/style/style.dart
@@ -51,7 +51,7 @@ enum Style {
null,
['autocomplete-field', 'autocompletefield'],
['FAutocompleteFieldStyle'],
- 'FAutocompleteFieldStyle autocompleteFieldStyle({\n required FColors colors,\n required FTextFieldStyle field,\n}) => FAutocompleteFieldStyle(\n composingTextStyle: field.contentTextStyle.apply([\n .all(.delta(decoration: () => .underline)),\n ]),\n typeaheadTextStyle: FVariants(\n field.contentTextStyle.base.copyWith(color: colors.mutedForeground),\n variants: {\n [.disabled, .not(.focused)]: null,\n },\n ),\n keyboardAppearance: field.keyboardAppearance,\n color: field.color,\n cursorColor: field.cursorColor,\n contentPadding: field.contentPadding,\n clearButtonPadding: field.clearButtonPadding,\n obscureButtonPadding: field.obscureButtonPadding,\n scrollPadding: field.scrollPadding,\n iconStyle: field.iconStyle,\n clearButtonStyle: field.clearButtonStyle,\n obscureButtonStyle: field.obscureButtonStyle,\n contentTextStyle: field.contentTextStyle,\n hintTextStyle: field.hintTextStyle,\n counterTextStyle: field.counterTextStyle,\n border: field.border,\n labelTextStyle: field.labelTextStyle,\n descriptionTextStyle: field.descriptionTextStyle,\n errorTextStyle: field.errorTextStyle,\n labelPadding: field.labelPadding,\n descriptionPadding: field.descriptionPadding,\n errorPadding: field.errorPadding,\n childPadding: field.childPadding,\n labelMotion: field.labelMotion,\n);\n',
+ 'FAutocompleteFieldStyle autocompleteFieldStyle({\n required FColors colors,\n required FTextFieldStyle field,\n}) => FAutocompleteFieldStyle(\n composingTextStyle: field.contentTextStyle.apply([\n .all(.delta(decoration: () => .underline)),\n ]),\n typeaheadTextStyle: FVariants(\n field.contentTextStyle.base.copyWith(color: colors.mutedForeground),\n variants: {\n [.disabled, .not(.focused)]: null,\n },\n ),\n keyboardAppearance: field.keyboardAppearance,\n color: field.color,\n cursorColor: field.cursorColor,\n cursorWidth: field.cursorWidth,\n cursorOpacityAnimates: field.cursorOpacityAnimates,\n contentPadding: field.contentPadding,\n clearButtonPadding: field.clearButtonPadding,\n obscureButtonPadding: field.obscureButtonPadding,\n scrollPadding: field.scrollPadding,\n iconStyle: field.iconStyle,\n clearButtonStyle: field.clearButtonStyle,\n obscureButtonStyle: field.obscureButtonStyle,\n contentTextStyle: field.contentTextStyle,\n hintTextStyle: field.hintTextStyle,\n counterTextStyle: field.counterTextStyle,\n border: field.border,\n labelTextStyle: field.labelTextStyle,\n descriptionTextStyle: field.descriptionTextStyle,\n errorTextStyle: field.errorTextStyle,\n labelPadding: field.labelPadding,\n descriptionPadding: field.descriptionPadding,\n errorPadding: field.errorPadding,\n childPadding: field.childPadding,\n labelMotion: field.labelMotion,\n);\n',
),
fautocompletesectionstyle(
'FAutocompleteSectionStyle',
@@ -319,6 +319,20 @@ enum Style {
['FMultiSelectTagStyle'],
'FMultiSelectTagStyle multiSelectTagStyle({\n required FColors colors,\n required FStyle style,\n required TextStyle textStyle,\n required EdgeInsetsGeometry padding,\n required BorderRadiusGeometry borderRadius,\n}) => FMultiSelectTagStyle(\n decoration: FVariants(\n ShapeDecoration(\n shape: RoundedSuperellipseBorder(borderRadius: borderRadius),\n color: colors.secondary,\n ),\n variants: {\n [.hovered, .pressed]: ShapeDecoration(\n shape: RoundedSuperellipseBorder(borderRadius: borderRadius),\n color: colors.hover(colors.secondary),\n ),\n [.disabled]: ShapeDecoration(\n shape: RoundedSuperellipseBorder(borderRadius: borderRadius),\n color: colors.disable(colors.secondary),\n ),\n },\n ),\n labelTextStyle: FVariants.from(\n textStyle.copyWith(color: colors.secondaryForeground, height: 1),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.secondaryForeground)),\n },\n ),\n iconStyle: FVariants.from(\n IconThemeData(color: colors.mutedForeground, size: textStyle.fontSize),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.mutedForeground)),\n },\n ),\n tappableStyle: style.tappableStyle.copyWith(motion: FTappableMotion.none),\n focusedOutlineStyle: style.focusedOutlineStyle,\n padding: padding,\n spacing: 4,\n);\n',
),
+ fotpfielditemstyles(
+ 'FOtpFieldItemStyles',
+ 'FVariants',
+ ['otp-field-items', 'otpfielditems'],
+ ['FOtpFieldItemStyles'],
+ 'FOtpFieldItemStyles otpFieldItemStyles({\n required FColors colors,\n required FTypography typography,\n required FStyle style,\n}) => FOtpFieldItemStyles(\n .from(\n FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n border: BorderDirectional(\n top: BorderSide(color: colors.border, width: style.borderWidth),\n bottom: BorderSide(color: colors.border, width: style.borderWidth),\n start: BorderSide(color: colors.border, width: style.borderWidth),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n variants: {\n [.start]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n borderRadius: BorderRadiusDirectional.only(\n topStart: style.borderRadius.sm.topLeft,\n bottomStart: style.borderRadius.sm.bottomLeft,\n ),\n border: BorderDirectional(\n top: BorderSide(color: colors.border, width: style.borderWidth),\n bottom: BorderSide(color: colors.border, width: style.borderWidth),\n start: BorderSide(color: colors.border, width: style.borderWidth),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.end]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n borderRadius: BorderRadiusDirectional.only(\n topEnd: style.borderRadius.sm.topRight,\n bottomEnd: style.borderRadius.sm.bottomRight,\n ),\n border: Border.all(color: colors.border, width: style.borderWidth),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.start.and(.end)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n borderRadius: style.borderRadius.sm,\n border: Border.all(color: colors.border, width: style.borderWidth),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.focused]: FOtpFieldItemStyle(\n decoration: ShapeDecoration(\n color: colors.card,\n shape: RoundedSuperellipseBorder(\n side: BorderSide(\n color: colors.foreground,\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.focused.and(.start)]: FOtpFieldItemStyle(\n decoration: ShapeDecoration(\n color: colors.card,\n shape: RoundedSuperellipseBorder(\n borderRadius: BorderRadiusDirectional.only(\n topStart: style.borderRadius.sm.topLeft,\n bottomStart: style.borderRadius.sm.bottomLeft,\n ),\n side: BorderSide(\n color: colors.foreground,\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.focused.and(.end)]: FOtpFieldItemStyle(\n decoration: ShapeDecoration(\n color: colors.card,\n shape: RoundedSuperellipseBorder(\n borderRadius: BorderRadiusDirectional.only(\n topEnd: style.borderRadius.sm.topRight,\n bottomEnd: style.borderRadius.sm.bottomRight,\n ),\n side: BorderSide(\n color: colors.foreground,\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.focused.and(.start).and(.end)]: FOtpFieldItemStyle(\n decoration: ShapeDecoration(\n color: colors.card,\n shape: RoundedSuperellipseBorder(\n borderRadius: style.borderRadius.sm,\n side: BorderSide(\n color: colors.foreground,\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.disabled]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n border: BorderDirectional(\n top: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n bottom: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n start: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n [.disabled.and(.start)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n borderRadius: BorderRadiusDirectional.only(\n topStart: style.borderRadius.sm.topLeft,\n bottomStart: style.borderRadius.sm.bottomLeft,\n ),\n border: BorderDirectional(\n top: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n bottom: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n start: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n [.disabled.and(.end)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n borderRadius: BorderRadiusDirectional.only(\n topEnd: style.borderRadius.sm.topRight,\n bottomEnd: style.borderRadius.sm.bottomRight,\n ),\n border: Border.all(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n [.disabled.and(.start).and(.end)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n borderRadius: style.borderRadius.sm,\n border: Border.all(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n [.error]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n border: BorderDirectional(\n top: BorderSide(color: colors.error, width: style.borderWidth),\n bottom: BorderSide(color: colors.error, width: style.borderWidth),\n start: BorderSide(color: colors.error, width: style.borderWidth),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.error.and(.start)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n borderRadius: BorderRadiusDirectional.only(\n topStart: style.borderRadius.sm.topLeft,\n bottomStart: style.borderRadius.sm.bottomLeft,\n ),\n border: BorderDirectional(\n top: BorderSide(color: colors.error, width: style.borderWidth),\n bottom: BorderSide(color: colors.error, width: style.borderWidth),\n start: BorderSide(color: colors.error, width: style.borderWidth),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.error.and(.end)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n borderRadius: BorderRadiusDirectional.only(\n topEnd: style.borderRadius.sm.topRight,\n bottomEnd: style.borderRadius.sm.bottomRight,\n ),\n border: Border.all(color: colors.error, width: style.borderWidth),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.error.and(.start).and(.end)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.card,\n borderRadius: style.borderRadius.sm,\n border: Border.all(color: colors.error, width: style.borderWidth),\n ),\n contentTextStyle: typography.sm.copyWith(color: colors.foreground),\n ),\n [.error.and(.disabled)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n border: BorderDirectional(\n top: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n bottom: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n start: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n [.error.and(.disabled).and(.start)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n borderRadius: BorderRadiusDirectional.only(\n topStart: style.borderRadius.sm.topLeft,\n bottomStart: style.borderRadius.sm.bottomLeft,\n ),\n border: BorderDirectional(\n top: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n bottom: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n start: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n [.error.and(.disabled).and(.end)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n borderRadius: BorderRadiusDirectional.only(\n topEnd: style.borderRadius.sm.topRight,\n bottomEnd: style.borderRadius.sm.bottomRight,\n ),\n border: Border.all(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n [.error.and(.disabled).and(.start).and(.end)]: FOtpFieldItemStyle(\n decoration: BoxDecoration(\n color: colors.disable(colors.card),\n borderRadius: style.borderRadius.sm,\n border: Border.all(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n ),\n contentTextStyle: typography.sm.copyWith(\n color: colors.disable(colors.foreground),\n ),\n ),\n },\n ),\n);\n',
+ ),
+ fotpfieldstyle(
+ 'FOtpFieldStyle',
+ null,
+ ['otp-field', 'otpfield'],
+ ['FOtpFieldStyle'],
+ 'FOtpFieldStyle otpFieldStyle({\n required FColors colors,\n required FTypography typography,\n required FStyle style,\n required bool touch,\n}) => FOtpFieldStyle(\n keyboardAppearance: colors.brightness,\n cursorColor: colors.primary,\n itemSize: touch ? const Size(44, 44) : const Size(36, 36),\n itemStyles: .inherit(colors: colors, typography: typography, style: style),\n dividerPadding: const .symmetric(horizontal: 4),\n dividerColor: FVariants(\n colors.foreground,\n variants: {\n [.disabled]: colors.disable(colors.foreground),\n },\n ),\n labelTextStyle: style.formFieldStyle.labelTextStyle,\n descriptionTextStyle: style.formFieldStyle.descriptionTextStyle,\n errorTextStyle: style.formFieldStyle.errorTextStyle,\n childPadding: const .symmetric(vertical: 2),\n cursorWidth: 2.0,\n dividerSize: const Size(12, 1),\n labelPadding: const .only(bottom: 6),\n descriptionPadding: const .only(top: 6),\n errorPadding: const .only(top: 6),\n labelMotion: const FLabelMotion(),\n);\n',
+ ),
fpaginationstyle(
'FPaginationStyle',
null,
@@ -538,7 +552,7 @@ enum Style {
null,
['text-field', 'textfield'],
['FTextFieldStyle'],
- 'FTextFieldStyle textFieldStyle({\n required FColors colors,\n required FStyle style,\n required FLabelStyle labelStyle,\n required TextStyle textStyle,\n required FVariants<\n FTextFieldVariantConstraint,\n FTextFieldVariant,\n IconThemeData,\n IconThemeDataDelta\n >\n iconStyle,\n required FButtonStyle buttonStyle,\n required EdgeInsetsGeometry contentPadding,\n}) => FTextFieldStyle(\n keyboardAppearance: colors.brightness,\n color: FVariants(\n colors.card,\n variants: {\n [.disabled]: colors.disable(colors.card),\n },\n ),\n cursorColor: colors.primary,\n contentPadding: contentPadding,\n iconStyle: iconStyle,\n clearButtonStyle: buttonStyle,\n obscureButtonStyle: buttonStyle.copyWith(\n tappableStyle: const .delta(\n motion: .delta(bounceTween: FTappableMotion.noBounceTween),\n ),\n ),\n contentTextStyle: FVariants.from(\n textStyle.copyWith(color: colors.foreground),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.foreground)),\n },\n ),\n hintTextStyle: FVariants.from(\n textStyle.copyWith(color: colors.mutedForeground),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.mutedForeground)),\n },\n ),\n counterTextStyle: FVariants.from(\n textStyle.copyWith(color: colors.foreground),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.foreground)),\n },\n ),\n border: FVariants(\n OutlineInputBorder(\n borderSide: BorderSide(color: colors.border, width: style.borderWidth),\n borderRadius: style.borderRadius.md,\n ),\n variants: {\n [.focused]: OutlineInputBorder(\n borderSide: BorderSide(color: colors.primary, width: style.borderWidth),\n borderRadius: style.borderRadius.md,\n ),\n [.disabled]: OutlineInputBorder(\n borderSide: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n borderRadius: style.borderRadius.md,\n ),\n [.error]: OutlineInputBorder(\n borderSide: BorderSide(color: colors.error, width: style.borderWidth),\n borderRadius: style.borderRadius.md,\n ),\n [.error.and(.disabled)]: OutlineInputBorder(\n borderSide: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n borderRadius: style.borderRadius.md,\n ),\n },\n ),\n labelTextStyle: style.formFieldStyle.labelTextStyle,\n descriptionTextStyle: style.formFieldStyle.descriptionTextStyle,\n errorTextStyle: style.formFieldStyle.errorTextStyle,\n labelPadding: labelStyle.labelPadding,\n descriptionPadding: labelStyle.descriptionPadding,\n errorPadding: labelStyle.errorPadding,\n childPadding: labelStyle.childPadding,\n clearButtonPadding: const .directional(end: 4),\n obscureButtonPadding: const .directional(end: 4),\n scrollPadding: const .all(20),\n labelMotion: const FLabelMotion(),\n);\n',
+ 'FTextFieldStyle textFieldStyle({\n required FColors colors,\n required FStyle style,\n required FLabelStyle labelStyle,\n required TextStyle textStyle,\n required FVariants<\n FTextFieldVariantConstraint,\n FTextFieldVariant,\n IconThemeData,\n IconThemeDataDelta\n >\n iconStyle,\n required FButtonStyle buttonStyle,\n required EdgeInsetsGeometry contentPadding,\n}) => FTextFieldStyle(\n keyboardAppearance: colors.brightness,\n color: FVariants(\n colors.card,\n variants: {\n [.disabled]: colors.disable(colors.card),\n },\n ),\n cursorColor: colors.primary,\n contentPadding: contentPadding,\n iconStyle: iconStyle,\n clearButtonStyle: buttonStyle,\n obscureButtonStyle: buttonStyle.copyWith(\n tappableStyle: const .delta(\n motion: .delta(bounceTween: FTappableMotion.noBounceTween),\n ),\n ),\n contentTextStyle: FVariants.from(\n textStyle.copyWith(color: colors.foreground),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.foreground)),\n },\n ),\n hintTextStyle: FVariants.from(\n textStyle.copyWith(color: colors.mutedForeground),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.mutedForeground)),\n },\n ),\n counterTextStyle: FVariants.from(\n textStyle.copyWith(color: colors.foreground),\n variants: {\n [.disabled]: .delta(color: colors.disable(colors.foreground)),\n },\n ),\n border: FVariants(\n OutlineInputBorder(\n borderSide: BorderSide(color: colors.border, width: style.borderWidth),\n borderRadius: style.borderRadius.md,\n ),\n variants: {\n [.focused]: OutlineInputBorder(\n borderSide: BorderSide(color: colors.primary, width: style.borderWidth),\n borderRadius: style.borderRadius.md,\n ),\n [.disabled]: OutlineInputBorder(\n borderSide: BorderSide(\n color: colors.disable(colors.border),\n width: style.borderWidth,\n ),\n borderRadius: style.borderRadius.md,\n ),\n [.error]: OutlineInputBorder(\n borderSide: BorderSide(color: colors.error, width: style.borderWidth),\n borderRadius: style.borderRadius.md,\n ),\n [.error.and(.disabled)]: OutlineInputBorder(\n borderSide: BorderSide(\n color: colors.disable(colors.error),\n width: style.borderWidth,\n ),\n borderRadius: style.borderRadius.md,\n ),\n },\n ),\n labelTextStyle: style.formFieldStyle.labelTextStyle,\n descriptionTextStyle: style.formFieldStyle.descriptionTextStyle,\n errorTextStyle: style.formFieldStyle.errorTextStyle,\n labelPadding: labelStyle.labelPadding,\n descriptionPadding: labelStyle.descriptionPadding,\n errorPadding: labelStyle.errorPadding,\n childPadding: labelStyle.childPadding,\n cursorWidth: 2.0,\n clearButtonPadding: const .directional(end: 4),\n obscureButtonPadding: const .directional(end: 4),\n scrollPadding: const .all(20),\n labelMotion: const FLabelMotion(),\n);\n',
),
ftilecontentstyle(
'FTileContentStyle',
diff --git a/forui/lib/forui.dart b/forui/lib/forui.dart
index c0577aa74..b25c23aa6 100644
--- a/forui/lib/forui.dart
+++ b/forui/lib/forui.dart
@@ -24,6 +24,7 @@ export 'widgets/header.dart';
export 'widgets/item.dart';
export 'widgets/label.dart';
export 'widgets/line_calendar.dart';
+export 'widgets/otp_field.dart';
export 'widgets/pagination.dart';
export 'widgets/picker.dart';
export 'widgets/popover.dart';
diff --git a/forui/lib/src/theme/theme_data.dart b/forui/lib/src/theme/theme_data.dart
index 061e28a08..a87471748 100644
--- a/forui/lib/src/theme/theme_data.dart
+++ b/forui/lib/src/theme/theme_data.dart
@@ -304,6 +304,10 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
@override
final FModalSheetStyle modalSheetStyle;
+ /// The OTP field style.
+ @override
+ final FOtpFieldStyle otpFieldStyle;
+
/// The pagination style.
///
/// ## CLI
@@ -610,6 +614,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
FLineCalendarStyle? lineCalendarStyle,
FMultiSelectStyle? multiSelectStyle,
FModalSheetStyle? modalSheetStyle,
+ FOtpFieldStyle? otpFieldStyle,
FPaginationStyle? paginationStyle,
FPersistentSheetStyle? persistentSheetStyle,
FPickerStyle? pickerStyle,
@@ -692,6 +697,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
multiSelectStyle:
multiSelectStyle ?? .inherit(colors: colors, typography: typography, style: style, touch: touch),
modalSheetStyle: modalSheetStyle ?? .inherit(colors: colors),
+ otpFieldStyle: otpFieldStyle ?? .inherit(colors: colors, typography: typography, style: style, touch: touch),
paginationStyle: paginationStyle ?? .inherit(colors: colors, typography: typography, style: style, touch: touch),
persistentSheetStyle: persistentSheetStyle ?? const FPersistentSheetStyle(),
pickerStyle: pickerStyle ?? .inherit(colors: colors, style: style, typography: typography),
@@ -820,6 +826,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
lineCalendarStyle: a.lineCalendarStyle.lerp(b.lineCalendarStyle, t),
multiSelectStyle: a.multiSelectStyle.lerp(b.multiSelectStyle, t),
modalSheetStyle: a.modalSheetStyle.lerp(b.modalSheetStyle, t),
+ otpFieldStyle: a.otpFieldStyle.lerp(b.otpFieldStyle, t),
paginationStyle: a.paginationStyle.lerp(b.paginationStyle, t),
persistentSheetStyle: a.persistentSheetStyle.lerp(b.persistentSheetStyle, t),
pickerStyle: a.pickerStyle.lerp(b.pickerStyle, t),
@@ -903,6 +910,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
required this.lineCalendarStyle,
required this.multiSelectStyle,
required this.modalSheetStyle,
+ required this.otpFieldStyle,
required this.paginationStyle,
required this.persistentSheetStyle,
required this.pickerStyle,
@@ -1389,6 +1397,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
FLineCalendarStyleDelta? lineCalendarStyle,
FMultiSelectStyleDelta? multiSelectStyle,
FModalSheetStyleDelta? modalSheetStyle,
+ FOtpFieldStyleDelta? otpFieldStyle,
FPaginationStyleDelta? paginationStyle,
FPersistentSheetStyleDelta? persistentSheetStyle,
FPickerStyleDelta? pickerStyle,
@@ -1457,6 +1466,7 @@ final class FThemeData with Diagnosticable, _$FThemeDataFunctions {
lineCalendarStyle: lineCalendarStyle?.call(this.lineCalendarStyle) ?? this.lineCalendarStyle,
multiSelectStyle: multiSelectStyle?.call(this.multiSelectStyle) ?? this.multiSelectStyle,
modalSheetStyle: modalSheetStyle?.call(this.modalSheetStyle) ?? this.modalSheetStyle,
+ otpFieldStyle: otpFieldStyle?.call(this.otpFieldStyle) ?? this.otpFieldStyle,
paginationStyle: paginationStyle?.call(this.paginationStyle) ?? this.paginationStyle,
persistentSheetStyle: persistentSheetStyle?.call(this.persistentSheetStyle) ?? this.persistentSheetStyle,
pickerStyle: pickerStyle?.call(this.pickerStyle) ?? this.pickerStyle,
diff --git a/forui/lib/src/widgets/autocomplete/autocomplete_style.dart b/forui/lib/src/widgets/autocomplete/autocomplete_style.dart
index 5654b1d1d..b1bd71b15 100644
--- a/forui/lib/src/widgets/autocomplete/autocomplete_style.dart
+++ b/forui/lib/src/widgets/autocomplete/autocomplete_style.dart
@@ -1,3 +1,5 @@
+import 'dart:ui';
+
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show InputBorder;
import 'package:flutter/widgets.dart';
@@ -116,6 +118,8 @@ class FAutocompleteFieldStyle extends FTextFieldStyle with _$FAutocompleteFieldS
required super.descriptionTextStyle,
required super.errorTextStyle,
super.cursorColor,
+ super.cursorWidth,
+ super.cursorOpacityAnimates,
super.contentPadding,
super.clearButtonPadding,
super.obscureButtonPadding,
@@ -140,6 +144,8 @@ class FAutocompleteFieldStyle extends FTextFieldStyle with _$FAutocompleteFieldS
keyboardAppearance: field.keyboardAppearance,
color: field.color,
cursorColor: field.cursorColor,
+ cursorWidth: field.cursorWidth,
+ cursorOpacityAnimates: field.cursorOpacityAnimates,
contentPadding: field.contentPadding,
clearButtonPadding: field.clearButtonPadding,
obscureButtonPadding: field.obscureButtonPadding,
diff --git a/forui/lib/src/widgets/item/item.dart b/forui/lib/src/widgets/item/item.dart
index 751e62e10..7a1513d22 100644
--- a/forui/lib/src/widgets/item/item.dart
+++ b/forui/lib/src/widgets/item/item.dart
@@ -471,12 +471,12 @@ class FItemStyle with Diagnosticable, _$FItemStyleFunctions {
static ({EdgeInsetsGeometry suffixedPadding, EdgeInsetsGeometry unsuffixedPadding, EdgeInsetsGeometry margin})
menuInsets({required bool touch}) => touch
? (
- suffixedPadding: const EdgeInsetsDirectional.fromSTEB(10, 12.5, 6, 12.5),
+ suffixedPadding: const .fromSTEB(10, 12.5, 6, 12.5),
unsuffixedPadding: const .symmetric(horizontal: 10, vertical: 12.5),
margin: const .symmetric(horizontal: 4),
)
: (
- suffixedPadding: const EdgeInsetsDirectional.fromSTEB(10, 6.5, 5, 6.5),
+ suffixedPadding: const .fromSTEB(10, 6.5, 5, 6.5),
unsuffixedPadding: const .symmetric(horizontal: 10, vertical: 6.5),
margin: const .symmetric(horizontal: 4),
);
@@ -485,12 +485,12 @@ class FItemStyle with Diagnosticable, _$FItemStyleFunctions {
static ({EdgeInsetsGeometry suffixedPadding, EdgeInsetsGeometry unsuffixedPadding, EdgeInsetsGeometry margin})
selectInsets({required bool touch}) => touch
? (
- suffixedPadding: const EdgeInsetsDirectional.fromSTEB(10, 12.5, 6, 12.5),
+ suffixedPadding: const .fromSTEB(10, 12.5, 6, 12.5),
unsuffixedPadding: const .symmetric(horizontal: 10, vertical: 12.5),
margin: const .symmetric(horizontal: 4),
)
: (
- suffixedPadding: const EdgeInsetsDirectional.fromSTEB(10, 6.5, 5, 6.5),
+ suffixedPadding: const .fromSTEB(10, 6.5, 5, 6.5),
unsuffixedPadding: const .symmetric(horizontal: 10, vertical: 6.5),
margin: const .symmetric(horizontal: 4),
);
diff --git a/forui/lib/src/widgets/item/item_mixin.dart b/forui/lib/src/widgets/item/item_mixin.dart
index 71bc7be66..6786f482e 100644
--- a/forui/lib/src/widgets/item/item_mixin.dart
+++ b/forui/lib/src/widgets/item/item_mixin.dart
@@ -102,7 +102,6 @@ mixin FItemMixin on Widget {
TraversalEdgeBehavior? submenuTraversalEdgeBehavior,
double submenuMaxHeight = .infinity,
FItemDivider submenuDivider = .full,
- bool? hover,
Key? key,
}) => .new(
title: title,
@@ -145,7 +144,6 @@ mixin FItemMixin on Widget {
submenuTraversalEdgeBehavior: submenuTraversalEdgeBehavior,
submenuMaxHeight: submenuMaxHeight,
submenuDivider: submenuDivider,
- hover: hover,
key: key,
);
diff --git a/forui/lib/src/widgets/otp_field/caret.dart b/forui/lib/src/widgets/otp_field/caret.dart
new file mode 100644
index 000000000..df83a57a1
--- /dev/null
+++ b/forui/lib/src/widgets/otp_field/caret.dart
@@ -0,0 +1,145 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+@internal
+class Caret extends StatefulWidget {
+ final Color color;
+ final double width;
+ final double height;
+ final bool cursorOpacityAnimates;
+
+ const Caret({
+ required this.color,
+ required this.width,
+ required this.height,
+ required this.cursorOpacityAnimates,
+ super.key,
+ });
+
+ @override
+ State createState() => _CaretState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(ColorProperty('color', color))
+ ..add(DoubleProperty('width', width))
+ ..add(DoubleProperty('height', height))
+ ..add(FlagProperty('cursorOpacityAnimates', value: cursorOpacityAnimates));
+ }
+}
+
+class _CaretState extends State with SingleTickerProviderStateMixin {
+ /// The duration of half a blink.
+ static const _blink = Duration(milliseconds: 500);
+
+ late final _controller = AnimationController(vsync: this)..addListener(() => setState(() {}));
+ late final _simulation = _DiscreteKeyFrameSimulation();
+ Timer? _timer;
+
+ @override
+ void initState() {
+ super.initState();
+ _startBlinking();
+ }
+
+ @override
+ void didUpdateWidget(covariant Caret old) {
+ super.didUpdateWidget(old);
+ if (old.cursorOpacityAnimates != widget.cursorOpacityAnimates) {
+ _timer?.cancel();
+ _controller.stop();
+ _startBlinking();
+ }
+ }
+
+ @override
+ void dispose() {
+ _timer?.cancel();
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _startBlinking() {
+ _controller.value = 1.0;
+ if (widget.cursorOpacityAnimates) {
+ // Schedule as async task to avoid blocking tester.pumpAndSettle indefinitely.
+ // See Flutter's editable_text.dart:4694β4701.
+ _controller.animateWith(_simulation).whenComplete(() => _timer = Timer(.zero, _startBlinking));
+ } else {
+ _timer = .periodic(_blink, (_) => setState(() => _controller.value = _controller.value == 0 ? 1 : 0));
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) => SizedBox(
+ width: widget.width,
+ height: widget.height,
+ child: ColoredBox(color: widget.color.withValues(alpha: widget.color.a * _controller.value)),
+ );
+}
+
+// Based on Flutter 3.41.5's _DiscreteKeyFrameSimulation in editable_text.dart:533.
+class _DiscreteKeyFrameSimulation extends Simulation {
+ // Based on Flutter 3.41.5's _KeyFrame in editable_text.dart:511.
+ //
+ // Values extracted from iOS 15.4 UIKit.
+ static const _iOSBlinkingCaretKeyFrames = [
+ (time: 0.0, value: 1.0),
+ (time: 0.5, value: 1.0),
+ (time: 0.5375, value: 0.75),
+ (time: 0.575, value: 0.5),
+ (time: 0.6125, value: 0.25),
+ (time: 0.65, value: 0.0),
+ (time: 0.85, value: 0.0),
+ (time: 0.8875, value: 0.25),
+ (time: 0.925, value: 0.5),
+ (time: 0.9625, value: 0.75),
+ (time: 1.0, value: 1.0),
+ ];
+
+ static const maxDuration = 1.0;
+
+ // The index of the keyframe corresponds to the most recent input `time`.
+ int _lastKeyFrame = 0;
+
+ _DiscreteKeyFrameSimulation();
+
+ @override
+ double x(double time) {
+ // Perform a linear search in the sorted key frame list, starting from the last key frame found, since the input
+ // `time` usually monotonically increases by a small amount.
+ int current;
+ final int end;
+ if (time < _iOSBlinkingCaretKeyFrames[_lastKeyFrame].time) {
+ // The simulation may have restarted. Search within the index range [0, _lastKeyFrame).
+ current = 0;
+ end = _lastKeyFrame;
+ } else {
+ current = _lastKeyFrame;
+ end = _iOSBlinkingCaretKeyFrames.length;
+ }
+
+ // Find the target key frame. Don't have to check (end - 1): if (end - 2) doesn't work we'll have to pick (end - 1)
+ // anyways.
+ while (current < end - 1) {
+ assert(_iOSBlinkingCaretKeyFrames[current].time <= time);
+ if (time < _iOSBlinkingCaretKeyFrames[current + 1].time) {
+ break;
+ }
+ current += 1;
+ }
+
+ _lastKeyFrame = current;
+ return _iOSBlinkingCaretKeyFrames[_lastKeyFrame].value;
+ }
+
+ @override
+ double dx(double time) => 0;
+
+ @override
+ bool isDone(double time) => maxDuration <= time;
+}
diff --git a/forui/lib/src/widgets/otp_field/otp_field.dart b/forui/lib/src/widgets/otp_field/otp_field.dart
new file mode 100644
index 000000000..f0a97a8d7
--- /dev/null
+++ b/forui/lib/src/widgets/otp_field/otp_field.dart
@@ -0,0 +1,513 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter/services.dart';
+
+import 'package:flutter_localizations/flutter_localizations.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/foundation/debug.dart';
+import 'package:forui/src/theme/variant.dart';
+import 'package:forui/src/widgets/otp_field/otp_field_control.dart';
+import 'package:forui/src/widgets/text_field/input/form_input.dart';
+
+/// Provides the [FOtpFieldStyle] to descendants.
+class FOtpFieldScope extends InheritedWidget {
+ /// Returns the [FOtpFieldScope] from the enclosing [FOtpField].
+ static FOtpFieldScope of(BuildContext context) {
+ assert(debugCheckHasAncestor('$FOtpField', context));
+ return context.dependOnInheritedWidgetOfExactType()!;
+ }
+
+ /// The style.
+ final FOtpFieldStyle style;
+
+ /// The current variants.
+ final Set variants;
+
+ /// Creates a [FOtpFieldScope].
+ const FOtpFieldScope({required this.style, required this.variants, required super.child, super.key});
+
+ @override
+ bool updateShouldNotify(FOtpFieldScope old) => style != old.style || !setEquals(variants, old.variants);
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('style', style))
+ ..add(IterableProperty('variants', variants));
+ }
+}
+
+/// A one-time password input field.
+///
+/// It is a [FormField] and therefore can be used in a [Form] widget.
+///
+/// To add a divider in the middle:
+/// ```dart
+/// FOtpField(
+/// control: .managed(
+/// children: [FOtpItem(), FOtpItem(), FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem(), FOtpItem()],
+/// ),
+/// )
+/// ```
+///
+/// See:
+/// * [FOtpController] for customizing the behavior of an OTP field.
+/// * [FOtpFieldStyle] for customizing the appearance of an OTP field.
+class FOtpField extends StatefulWidget with FFormFieldProperties {
+ /// The default input formatters that restrict input to digits only.
+ static final List defaultInputFormatters = [FilteringTextInputFormatter.digitsOnly];
+
+ /// The default builder that returns the child as-is.
+ static Widget defaultBuilder(
+ BuildContext context,
+ FOtpFieldStyle style,
+ Set variants,
+ Widget child,
+ ) => child;
+
+ /// Defines how the OTP field's state is controlled.
+ ///
+ /// Defaults to [FOtpFieldControl.managed].
+ final FOtpFieldControl control;
+
+ /// The style.
+ final FOtpFieldStyleDelta style;
+
+ /// {@macro forui.text_field.builder}
+ final FFieldBuilder builder;
+
+ @override
+ final Widget? label;
+
+ @override
+ final Widget? description;
+
+ /// {@macro forui.text_field.magnifier_configuration}
+ final TextMagnifierConfiguration? magnifierConfiguration;
+
+ /// {@macro forui.text_field_groupId}
+ final Object groupId;
+
+ /// {@macro forui.text_field.focusNode}
+ final FocusNode? focusNode;
+
+ /// The type of keyboard to use for editing the text. Defaults to [TextInputType.text].
+ final TextInputType? keyboardType;
+
+ /// {@macro forui.text_field.textInputAction}
+ final TextInputAction? textInputAction;
+
+ /// {@macro forui.text_field.textCapitalization}
+ final TextCapitalization textCapitalization;
+
+ /// {@macro forui.text_field.textDirection}
+ final TextDirection? textDirection;
+
+ /// {@macro forui.text_field.autofocus}
+ final bool autofocus;
+
+ /// {@macro forui.text_field.statesController}
+ final WidgetStatesController? statesController;
+
+ /// {@macro forui.text_field.readOnly}
+ final bool readOnly;
+
+ /// {@macro forui.text_field.onTap}
+ final GestureTapCallback? onTap;
+
+ /// {@macro forui.text_field.onTapOutside}
+ final TapRegionCallback? onTapOutside;
+
+ /// {@macro forui.text_field.onTapAlwaysCalled}
+ final bool onTapAlwaysCalled;
+
+ /// {@macro forui.text_field.onEditingComplete}
+ final VoidCallback? onEditingComplete;
+
+ /// {@macro forui.text_field.onSubmit}
+ final ValueChanged? onSubmit;
+
+ /// {@macro forui.text_field.onAppPrivateCommand}
+ final AppPrivateCommandCallback? onAppPrivateCommand;
+
+ /// {@macro forui.text_field.inputFormatters}
+ final List? inputFormatters;
+
+ @override
+ final bool enabled;
+
+ /// {@macro forui.text_field.ignorePointers}
+ final bool? ignorePointers;
+
+ /// {@macro forui.text_field.enableInteractiveSelection}
+ final bool enableInteractiveSelection;
+
+ /// {@macro forui.text_field.selectionControls}
+ final TextSelectionControls? selectionControls;
+
+ /// {@macro forui.text_field.dragStartBehavior}
+ final DragStartBehavior dragStartBehavior;
+
+ /// {@macro forui.text_field.mouseCursor}
+ final MouseCursor? mouseCursor;
+
+ /// {@macro forui.text_field.autofillHints}
+ final Iterable? autofillHints;
+
+ /// {@macro forui.text_field.restorationId}
+ final String? restorationId;
+
+ /// {@macro forui.text_field.stylusHandwritingEnabled}
+ final bool stylusHandwritingEnabled;
+
+ /// {@macro forui.text_field.enableIMEPersonalizedLearning}
+ final bool enableIMEPersonalizedLearning;
+
+ /// {@macro forui.text_field.contentInsertionConfiguration}
+ final ContentInsertionConfiguration? contentInsertionConfiguration;
+
+ /// {@macro forui.text_field.contextMenuBuilder}
+ final EditableTextContextMenuBuilder? contextMenuBuilder;
+
+ /// {@macro forui.text_field.canRequestFocus}
+ final bool canRequestFocus;
+
+ /// {@macro forui.text_field.undoController}
+ final UndoHistoryController? undoController;
+
+ @override
+ final FormFieldSetter? onSaved;
+
+ @override
+ final VoidCallback? onReset;
+
+ @override
+ final FormFieldValidator? validator;
+
+ @override
+ final AutovalidateMode autovalidateMode;
+
+ @override
+ final String? forceErrorText;
+
+ @override
+ final Widget Function(BuildContext context, String message) errorBuilder;
+
+ /// {@macro forui.foundation.doc_templates.formFieldKey}
+ final Key? formFieldKey;
+
+ /// Creates an [FOtpField].
+ FOtpField({
+ this.control = const .managed(),
+ this.style = const .context(),
+ this.builder = defaultBuilder,
+ this.label,
+ this.description,
+ this.magnifierConfiguration,
+ this.groupId = EditableText,
+ this.focusNode,
+ this.keyboardType = .text,
+ this.textInputAction = .done,
+ this.textCapitalization = .none,
+ this.textDirection,
+ this.autofocus = false,
+ this.statesController,
+ this.readOnly = false,
+ this.onTap,
+ this.onTapOutside,
+ this.onTapAlwaysCalled = false,
+ this.onEditingComplete,
+ this.onSubmit,
+ this.onAppPrivateCommand,
+ List? inputFormatters,
+ this.enabled = true,
+ this.ignorePointers,
+ this.enableInteractiveSelection = true,
+ this.selectionControls,
+ this.dragStartBehavior = .start,
+ this.mouseCursor,
+ this.autofillHints = const [AutofillHints.oneTimeCode],
+ this.restorationId,
+ this.stylusHandwritingEnabled = true,
+ this.enableIMEPersonalizedLearning = true,
+ this.contentInsertionConfiguration,
+ this.contextMenuBuilder,
+ this.canRequestFocus = true,
+ this.undoController,
+ this.onSaved,
+ this.onReset,
+ this.validator,
+ this.autovalidateMode = .disabled,
+ this.forceErrorText,
+ this.errorBuilder = FFormFieldProperties.defaultErrorBuilder,
+ this.formFieldKey,
+ super.key,
+ }) : inputFormatters = inputFormatters ?? defaultInputFormatters;
+
+ @override
+ State createState() => _FOtpFieldState();
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(DiagnosticsProperty('control', control))
+ ..add(DiagnosticsProperty('style', style))
+ ..add(ObjectFlagProperty.has('builder', builder))
+ ..add(DiagnosticsProperty('magnifierConfiguration', magnifierConfiguration))
+ ..add(DiagnosticsProperty('groupId', groupId))
+ ..add(DiagnosticsProperty('focusNode', focusNode))
+ ..add(DiagnosticsProperty('keyboardType', keyboardType))
+ ..add(EnumProperty('textInputAction', textInputAction))
+ ..add(EnumProperty('textCapitalization', textCapitalization))
+ ..add(EnumProperty('textDirection', textDirection))
+ ..add(FlagProperty('autofocus', value: autofocus, ifTrue: 'autofocus'))
+ ..add(DiagnosticsProperty('statesController', statesController))
+ ..add(FlagProperty('readOnly', value: readOnly, ifTrue: 'readOnly'))
+ ..add(ObjectFlagProperty.has('onTap', onTap))
+ ..add(ObjectFlagProperty.has('onTapOutside', onTapOutside))
+ ..add(FlagProperty('onTapAlwaysCalled', value: onTapAlwaysCalled, ifTrue: 'onTapAlwaysCalled'))
+ ..add(ObjectFlagProperty.has('onEditingComplete', onEditingComplete))
+ ..add(ObjectFlagProperty.has('onSubmit', onSubmit))
+ ..add(ObjectFlagProperty.has('onAppPrivateCommand', onAppPrivateCommand))
+ ..add(IterableProperty('inputFormatters', inputFormatters))
+ ..add(FlagProperty('enabled', value: enabled, ifTrue: 'enabled'))
+ ..add(FlagProperty('ignorePointers', value: ignorePointers, ifTrue: 'ignorePointers'))
+ ..add(
+ FlagProperty(
+ 'enableInteractiveSelection',
+ value: enableInteractiveSelection,
+ ifTrue: 'enableInteractiveSelection',
+ ),
+ )
+ ..add(DiagnosticsProperty('selectionControls', selectionControls))
+ ..add(EnumProperty('dragStartBehavior', dragStartBehavior))
+ ..add(DiagnosticsProperty('mouseCursor', mouseCursor))
+ ..add(IterableProperty('autofillHints', autofillHints))
+ ..add(StringProperty('restorationId', restorationId))
+ ..add(
+ FlagProperty('stylusHandwritingEnabled', value: stylusHandwritingEnabled, ifTrue: 'stylusHandwritingEnabled'),
+ )
+ ..add(
+ FlagProperty(
+ 'enableIMEPersonalizedLearning',
+ value: enableIMEPersonalizedLearning,
+ ifTrue: 'enableIMEPersonalizedLearning',
+ ),
+ )
+ ..add(DiagnosticsProperty('contentInsertionConfiguration', contentInsertionConfiguration))
+ ..add(ObjectFlagProperty.has('contextMenuBuilder', contextMenuBuilder))
+ ..add(FlagProperty('canRequestFocus', value: canRequestFocus, ifTrue: 'canRequestFocus'))
+ ..add(DiagnosticsProperty('undoController', undoController))
+ ..add(ObjectFlagProperty.has('onSaved', onSaved))
+ ..add(ObjectFlagProperty.has('onReset', onReset))
+ ..add(ObjectFlagProperty.has('validator', validator))
+ ..add(EnumProperty('autovalidateMode', autovalidateMode))
+ ..add(StringProperty('forceErrorText', forceErrorText))
+ ..add(ObjectFlagProperty.has('errorBuilder', errorBuilder))
+ ..add(DiagnosticsProperty('formFieldKey', formFieldKey, level: .debug));
+ }
+}
+
+class _FOtpFieldState extends State {
+ late FOtpController _controller;
+ late WidgetStatesController _statesController;
+
+ // The error widget cannot be computed inline because `_statesController` is updated one frame after `build`. When
+ // transitioning from error β non-error, this causes the build method to be called once with an error variant but no
+ // error widget, causing a layout "jump" and the error text disappearing immediately instead of fading out.
+ Widget _error = const SizedBox();
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = widget.control.create(_handleOnChange);
+ _statesController = widget.statesController ?? .new();
+ _statesController.addListener(_handleStatesChange);
+ }
+
+ @override
+ void didUpdateWidget(covariant FOtpField old) {
+ super.didUpdateWidget(old);
+ _controller = widget.control.update(old.control, _controller, _handleOnChange).$1;
+
+ if (widget.statesController != old.statesController) {
+ if (old.statesController == null) {
+ _statesController.dispose();
+ } else {
+ _statesController.removeListener(_handleStatesChange);
+ }
+
+ _statesController = widget.statesController ?? .new();
+ _statesController.addListener(_handleStatesChange);
+ }
+ }
+
+ @override
+ void dispose() {
+ if (widget.statesController == null) {
+ _statesController.dispose();
+ } else {
+ _statesController.removeListener(_handleStatesChange);
+ }
+ widget.control.dispose(_controller, _handleOnChange);
+ super.dispose();
+ }
+
+ void _handleOnChange() {
+ if (widget.control case FOtpFieldManagedControl(:final onChange?)) {
+ onChange(_controller.value);
+ }
+ }
+
+ void _handleStatesChange() => SchedulerBinding.instance.addPostFrameCallback((_) {
+ if (mounted) {
+ setState(() {});
+ }
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final style = widget.style(context.theme.otpFieldStyle);
+ final variants = toTextFieldVariants(context.platformVariant, _statesController.value);
+
+ return FormInput(
+ key: widget.formFieldKey,
+ controller: _controller,
+ initialValue: _controller.text,
+ onSaved: widget.onSaved,
+ onReset: widget.onReset,
+ validator: widget.validator,
+ enabled: widget.enabled,
+ autovalidateMode: widget.autovalidateMode,
+ forceErrorText: widget.forceErrorText,
+ restorationId: widget.restorationId,
+ builder: (state) {
+ final errorText = state.errorText;
+ final error = errorText == null ? null : widget.errorBuilder(state.context, errorText);
+ if (error != null) {
+ _error = error;
+ }
+
+ /// A stripped down version of the input used by [FTextField].
+ final textfield = IntrinsicWidth(
+ child: CallbackShortcuts(
+ bindings: {
+ const SingleActivator(.arrowLeft): () => _controller.traverse(forward: false),
+ const SingleActivator(.arrowRight): () => _controller.traverse(forward: true),
+ },
+ child: FOtpFieldScope(
+ style: style,
+ variants: variants,
+ child: TextField(
+ controller: _controller,
+ decoration: InputDecoration(
+ // This is necessary to prevent the TextField's height from shrinking to the textstyle's height.
+ border: WidgetStateInputBorder.resolveWith(
+ (_) => const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent), gapPadding: 0),
+ ),
+ constraints: .tightFor(height: style.itemSize.height),
+ contentPadding: .zero,
+ error: error == null ? null : const SizedBox(),
+ ),
+ focusNode: widget.focusNode,
+ undoController: widget.undoController,
+ cursorErrorColor: style.cursorColor,
+ cursorWidth: style.cursorWidth,
+ cursorOpacityAnimates:
+ style.cursorOpacityAnimates ??
+ (context.platformVariant == .iOS || context.platformVariant == .macOS),
+ keyboardType: widget.keyboardType,
+ textInputAction: widget.textInputAction,
+ textCapitalization: widget.textCapitalization,
+ textDirection: widget.textDirection,
+ readOnly: widget.readOnly,
+ showCursor: false,
+ autofocus: widget.autofocus,
+ statesController: _statesController,
+ autocorrect: false,
+ enableSuggestions: false,
+ onTap: widget.onTap,
+ onTapOutside: widget.onTapOutside,
+ onTapAlwaysCalled: widget.onTapAlwaysCalled,
+ onEditingComplete: widget.onEditingComplete,
+ onSubmitted: widget.onSubmit,
+ onAppPrivateCommand: widget.onAppPrivateCommand,
+ inputFormatters: widget.inputFormatters,
+ enabled: widget.enabled,
+ ignorePointers: widget.ignorePointers,
+ enableInteractiveSelection: widget.enableInteractiveSelection,
+ keyboardAppearance: style.keyboardAppearance,
+ scrollPadding: .zero,
+ dragStartBehavior: widget.dragStartBehavior,
+ mouseCursor: widget.mouseCursor,
+ selectAllOnFocus: false,
+ selectionControls: widget.selectionControls,
+ scrollPhysics: const NeverScrollableScrollPhysics(),
+ autofillHints: widget.autofillHints,
+ restorationId: widget.restorationId,
+ stylusHandwritingEnabled: widget.stylusHandwritingEnabled,
+ enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
+ contentInsertionConfiguration: widget.contentInsertionConfiguration,
+ contextMenuBuilder: widget.contextMenuBuilder,
+ canRequestFocus: widget.canRequestFocus,
+ magnifierConfiguration: widget.magnifierConfiguration,
+ groupId: widget.groupId,
+ ),
+ ),
+ ),
+ );
+
+ Widget field = FLabel(
+ axis: .vertical,
+ variants: variants as Set,
+ label: widget.label,
+ style: style,
+ description: widget.description,
+ error: _error,
+ child: widget.builder(context, style, variants, textfield),
+ );
+
+ field = MergeSemantics(
+ child: Material(
+ color: Colors.transparent,
+ child: Theme(
+ data: Theme.of(context).copyWith(
+ visualDensity: .standard,
+ textSelectionTheme: TextSelectionThemeData(
+ cursorColor: Colors.transparent,
+ selectionColor: Colors.transparent,
+ selectionHandleColor: style.cursorColor,
+ ),
+ ),
+ child: CupertinoTheme(
+ data: CupertinoTheme.of(context).copyWith(primaryColor: style.cursorColor),
+ child: field,
+ ),
+ ),
+ ),
+ );
+
+ final materialLocalizations = Localizations.of(context, MaterialLocalizations);
+ if (materialLocalizations == null) {
+ field = Localizations(
+ locale: Localizations.maybeLocaleOf(context) ?? const Locale('en', 'US'),
+ delegates: const [
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ child: field,
+ );
+ }
+
+ return field;
+ },
+ );
+ }
+}
diff --git a/forui/lib/src/widgets/otp_field/otp_field_control.dart b/forui/lib/src/widgets/otp_field/otp_field_control.dart
new file mode 100644
index 000000000..6966e2894
--- /dev/null
+++ b/forui/lib/src/widgets/otp_field/otp_field_control.dart
@@ -0,0 +1,224 @@
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:collection/collection.dart';
+
+import 'package:forui/forui.dart';
+
+part 'otp_field_control.control.dart';
+
+/// A [FOtpFieldControl] defines how a [FOtpField] is controlled.
+///
+/// {@macro forui.foundation.doc_templates.control}
+sealed class FOtpFieldControl with Diagnosticable, _$FOtpFieldControlMixin {
+ /// Creates a [FOtpFieldControl].
+ const factory FOtpFieldControl.managed({
+ FOtpController? controller,
+ List children,
+ TextEditingValue? initial,
+ ValueChanged? onChange,
+ }) = FOtpFieldManagedControl;
+
+ const FOtpFieldControl._();
+
+ (FOtpController, bool) _update(FOtpFieldControl old, FOtpController controller, VoidCallback callback);
+}
+
+/// A [FOtpFieldManagedControl] enables widgets to manage their own controller internally while exposing parameters for
+/// common configurations.
+///
+/// {@macro forui.foundation.doc_templates.managed}
+class FOtpFieldManagedControl extends FOtpFieldControl with _$FOtpFieldManagedControlMixin {
+ /// The controller.
+ @override
+ final FOtpController? controller;
+
+ /// The initial value. Defaults to null.
+ ///
+ /// Ignored if [controller] is provided. Pass the initial value to the controller instead.
+ ///
+ /// ## Contract
+ /// Throws [AssertionError] if [initial] and [controller] are both provided.
+ @override
+ final TextEditingValue? initial;
+
+ /// The children, which should be [FOtpItemMixin]s or [FOtpDivider]s. Defaults to six [FOtpItem]s.
+ ///
+ /// Ignored if [controller] is provided. Pass children to the controller instead.
+ @override
+ final List children;
+
+ /// Called when the value changes.
+ @override
+ final ValueChanged? onChange;
+
+ /// Creates a [FOtpFieldControl].
+ const FOtpFieldManagedControl({
+ this.controller,
+ this.children = const [FOtpItem(), FOtpItem(), FOtpItem(), FOtpItem(), FOtpItem(), FOtpItem()],
+ this.initial,
+ this.onChange,
+ }) : assert(
+ controller == null || initial == null,
+ 'Cannot provide both controller and initial value. Pass initial value to the controller instead.',
+ ),
+ super._();
+
+ @override
+ FOtpController createController() => controller ?? FOtpController(children: children, value: initial ?? .empty);
+}
+
+// TODO: Add support for lifted OTP field.
+class _Lifted extends FOtpFieldControl with _$_LiftedMixin {
+ const _Lifted() : super._();
+
+ @override
+ FOtpController createController() => throw UnimplementedError();
+
+ @override
+ void _updateController(FOtpController _) {}
+}
+
+@internal
+extension InternalFOtpController on FOtpController {
+ /// The currently focused item index.
+ int get focused => _focused;
+}
+
+/// A controller that manages the state of an [FOtpField].
+///
+/// This controller does not handle formatting internally. Use [TextInputFormatter]s on [FOtpField] to restrict input.
+class FOtpController extends TextEditingController {
+ /// The children, which should be [FOtpItemMixin]s or [FOtpDivider]s. Defaults to six [FOtpItem]s.
+ ///
+ /// For example, to add a divider in the middle:
+ /// ```dart
+ /// FOtpField(
+ /// controller: FOtpController(
+ /// children: [FOtpItem(), FOtpItem(), FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem(), FOtpItem()],
+ /// ),
+ /// )
+ /// ```
+ final List children;
+ final int _length;
+ int _focused;
+
+ /// Creates a [FOtpController].
+ FOtpController({
+ this.children = const [FOtpItem(), FOtpItem(), FOtpItem(), FOtpItem(), FOtpItem(), FOtpItem()],
+ TextEditingValue value = .empty,
+ }) : _length = children.whereType().length,
+ _focused = 0,
+ super.fromValue(value);
+
+ @override
+ TextSpan buildTextSpan({required BuildContext context, required bool withComposing, TextStyle? style}) {
+ // It is very unlikely that the OTP code will contain grapheme clusters but there's no harm being calamitous.
+ final characters = text.characters;
+ final variants = FOtpFieldScope.of(context).variants;
+
+ final spans = [];
+ int item = 0;
+ for (final (i, child) in children.indexed) {
+ if (child is FOtpItemMixin) {
+ final character = characters.elementAtOrNull(item);
+ spans.add(
+ WidgetSpan(
+ alignment: .middle,
+ child: FOtpItemScope(
+ character: character,
+ focused: switch (selection) {
+ _ when !variants.contains(FTextFieldVariant.focused) => false,
+ final s when s.isCollapsed => _focused == item,
+ final s => s.start <= item && item < s.end,
+ },
+ start: i == 0 || children[i - 1] is! FOtpItemMixin,
+ end: i == children.length - 1 || children[i + 1] is! FOtpItemMixin,
+ child: child,
+ ),
+ ),
+ );
+ item++;
+ continue;
+ }
+
+ spans.add(WidgetSpan(alignment: .middle, child: child));
+ }
+
+ return TextSpan(children: Directionality.of(context) == .ltr ? spans : spans.reversed.toList());
+ }
+
+ /// Handles the traversal of the OTP items when the user presses the left or right arrow key.
+ void traverse({required bool forward}) {
+ /// The default traversal collapses to the start/end of the selection range. We instead move to the previous/next
+ /// item outside it.
+ if (selection.isCollapsed) {
+ final adjustment = (forward ? 1 : -1);
+ value = value.copyWith(selection: .collapsed(offset: (selection.baseOffset + adjustment).clamp(0, text.length)));
+ } else {
+ final offset = forward ? selection.end : max(selection.start - 1, 0);
+ value = value.copyWith(selection: .collapsed(offset: offset));
+ }
+ }
+
+ @override
+ set value(TextEditingValue newValue) {
+ if (newValue == value) {
+ return;
+ }
+
+ /// Truncates the text to the maximum length.
+ if (newValue.text.characters.take(_length).string case final truncated when truncated != newValue.text) {
+ _focused = (truncated.length - 1).clamp(0, _length - 1);
+ super.value = TextEditingValue(
+ text: truncated,
+ selection: .collapsed(offset: truncated.length),
+ );
+ return;
+ }
+
+ /// Calculates the focused index and caret position. Arrow key events are intercepted and routed via `traverse`
+ /// to avoid conflicts.
+ ///
+ /// WidgetSpan offset/affinity calculations are fucked. See https://github.com/flutter/flutter/issues/107432.
+ /// Tapping the right half of the first item produces while other items produce
+ /// .
+ _focused = newValue.selection.baseOffset.clamp(0, _length - 1);
+ if (newValue.selection.isCollapsed) {
+ /// Corrects the focused index when tapping the right half of the first item.
+ if (newValue.selection.baseOffset == 1 && newValue.selection.affinity == .upstream) {
+ _focused = 0;
+ super.value = newValue;
+ return;
+ }
+
+ /// Handles replacement/deletion of middle items.
+ if (newValue.text.length != newValue.selection.baseOffset) {
+ /// Selects the previous item on deletion.
+ if (newValue.text.length != text.length) {
+ super.value = newValue.copyWith(
+ selection: TextSelection(
+ baseOffset: max(newValue.selection.baseOffset - 1, 0),
+ extentOffset: newValue.selection.baseOffset,
+ ),
+ );
+ return;
+ }
+
+ /// Selects the middle item at the caret so that backspace deletes it and typing replaces it.
+ super.value = newValue.copyWith(
+ selection: TextSelection(
+ baseOffset: newValue.selection.baseOffset,
+ extentOffset: newValue.selection.baseOffset + 1,
+ ),
+ );
+ return;
+ }
+ }
+
+ super.value = newValue;
+ }
+}
diff --git a/forui/lib/src/widgets/otp_field/otp_field_style.dart b/forui/lib/src/widgets/otp_field/otp_field_style.dart
new file mode 100644
index 000000000..56127380b
--- /dev/null
+++ b/forui/lib/src/widgets/otp_field/otp_field_style.dart
@@ -0,0 +1,375 @@
+import 'dart:ui';
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+
+import 'package:meta/meta.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/foundation/annotations.dart';
+import 'package:forui/src/theme/variant.dart';
+
+@Variants('FOtpFieldItem', {
+ 'disabled': (2, 'The semantic variant when this widget is disabled and cannot be interacted with.'),
+ 'error': (2, 'The semantic variant when this widget is in an error state.'),
+ 'focused': (1, 'The interaction variant when the given widget or any of its descendants have focus.'),
+ 'hovered': (1, 'The interaction variant when the user drags their mouse cursor over the given widget.'),
+ 'pressed': (1, 'The interaction variant when the user is actively pressing down on the given widget.'),
+ 'start': (1, 'The variant for the first item in a group (e.g. the first item or the first item after a divider).'),
+ 'end': (1, 'The variant for the last item in a group (e.g. the last item or the last item before a divider).'),
+})
+part 'otp_field_style.design.dart';
+
+/// The [FOtpField]'s style.
+class FOtpFieldStyle extends FLabelStyle with _$FOtpFieldStyleFunctions {
+ /// The appearance of the keyboard. Defaults to [FColors.brightness].
+ ///
+ /// This setting is only honored on iOS devices.
+ @override
+ 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.
+ @override
+ final Color cursorColor;
+
+ /// The width of the cursor. Defaults to 2.0.
+ ///
+ /// The cursor indicates the current location of text insertion point in the field.
+ @override
+ final double cursorWidth;
+
+ /// Whether the cursor opacity animates. Defaults to the current platform's behavior (true on iOS and macOS, false on
+ /// other platforms).
+ @override
+ final bool? cursorOpacityAnimates;
+
+ /// The item size.
+ @override
+ final Size itemSize;
+
+ /// The item styles per variant.
+ @override
+ final FOtpFieldItemStyles itemStyles;
+
+ /// The divider's padding. Defaults to `EdgeInsets.symmetric(horizontal: 8)`.
+ @override
+ final EdgeInsetsGeometry dividerPadding;
+
+ /// The divider's size. Defaults to `Size(12, 1)`.
+ @override
+ final Size dividerSize;
+
+ /// The divider's color.
+ @override
+ final FVariants dividerColor;
+
+ /// Creates a [FOtpFieldStyle].
+ FOtpFieldStyle({
+ required this.keyboardAppearance,
+ required this.itemSize,
+ required this.itemStyles,
+ required this.dividerColor,
+ required super.labelTextStyle,
+ required super.descriptionTextStyle,
+ required super.errorTextStyle,
+ this.cursorColor = CupertinoColors.activeBlue,
+ this.cursorWidth = 2.0,
+ this.cursorOpacityAnimates,
+ this.dividerPadding = const .symmetric(horizontal: 8),
+ this.dividerSize = const Size(12, 1),
+ super.labelPadding = const .only(bottom: 6),
+ super.descriptionPadding = const .only(top: 6),
+ super.errorPadding = const .only(top: 6),
+ super.childPadding,
+ super.labelMotion,
+ });
+
+ /// Creates a [FOtpFieldStyle] that inherits its properties.
+ FOtpFieldStyle.inherit({
+ required FColors colors,
+ required FTypography typography,
+ required FStyle style,
+ required bool touch,
+ }) : this(
+ keyboardAppearance: colors.brightness,
+ cursorColor: colors.primary,
+ itemSize: touch ? const Size(44, 44) : const Size(36, 36),
+ itemStyles: .inherit(colors: colors, typography: typography, style: style),
+ dividerPadding: const .symmetric(horizontal: 4),
+ dividerColor: FVariants(
+ colors.foreground,
+ variants: {
+ [.disabled]: colors.disable(colors.foreground),
+ },
+ ),
+ labelTextStyle: style.formFieldStyle.labelTextStyle,
+ descriptionTextStyle: style.formFieldStyle.descriptionTextStyle,
+ errorTextStyle: style.formFieldStyle.errorTextStyle,
+ childPadding: const .symmetric(vertical: 2),
+ );
+}
+
+/// The [FOtpFieldItemStyle]s for each variant.
+extension type FOtpFieldItemStyles(
+ FVariants _
+)
+ implements
+ FVariants {
+ /// Creates [FOtpFieldItemStyles] that inherit their properties.
+ factory FOtpFieldItemStyles.inherit({
+ required FColors colors,
+ required FTypography typography,
+ required FStyle style,
+ }) => FOtpFieldItemStyles(
+ .from(
+ FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ border: BorderDirectional(
+ top: BorderSide(color: colors.border, width: style.borderWidth),
+ bottom: BorderSide(color: colors.border, width: style.borderWidth),
+ start: BorderSide(color: colors.border, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ variants: {
+ // --- default ---
+ [.start]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ borderRadius: BorderRadiusDirectional.only(
+ topStart: style.borderRadius.sm.topLeft,
+ bottomStart: style.borderRadius.sm.bottomLeft,
+ ),
+ border: BorderDirectional(
+ top: BorderSide(color: colors.border, width: style.borderWidth),
+ bottom: BorderSide(color: colors.border, width: style.borderWidth),
+ start: BorderSide(color: colors.border, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.end]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ borderRadius: BorderRadiusDirectional.only(
+ topEnd: style.borderRadius.sm.topRight,
+ bottomEnd: style.borderRadius.sm.bottomRight,
+ ),
+ border: Border.all(color: colors.border, width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.start.and(.end)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ borderRadius: style.borderRadius.sm,
+ border: Border.all(color: colors.border, width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ // --- focused ---
+ [.focused]: FOtpFieldItemStyle(
+ decoration: ShapeDecoration(
+ color: colors.card,
+ shape: RoundedSuperellipseBorder(
+ side: BorderSide(color: colors.foreground, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.focused.and(.start)]: FOtpFieldItemStyle(
+ decoration: ShapeDecoration(
+ color: colors.card,
+ shape: RoundedSuperellipseBorder(
+ borderRadius: BorderRadiusDirectional.only(
+ topStart: style.borderRadius.sm.topLeft,
+ bottomStart: style.borderRadius.sm.bottomLeft,
+ ),
+ side: BorderSide(color: colors.foreground, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.focused.and(.end)]: FOtpFieldItemStyle(
+ decoration: ShapeDecoration(
+ color: colors.card,
+ shape: RoundedSuperellipseBorder(
+ borderRadius: BorderRadiusDirectional.only(
+ topEnd: style.borderRadius.sm.topRight,
+ bottomEnd: style.borderRadius.sm.bottomRight,
+ ),
+ side: BorderSide(color: colors.foreground, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.focused.and(.start).and(.end)]: FOtpFieldItemStyle(
+ decoration: ShapeDecoration(
+ color: colors.card,
+ shape: RoundedSuperellipseBorder(
+ borderRadius: style.borderRadius.sm,
+ side: BorderSide(color: colors.foreground, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ // --- disabled ---
+ [.disabled]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ border: BorderDirectional(
+ top: BorderSide(color: colors.disable(colors.border), width: style.borderWidth),
+ bottom: BorderSide(color: colors.disable(colors.border), width: style.borderWidth),
+ start: BorderSide(color: colors.disable(colors.border), width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ [.disabled.and(.start)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ borderRadius: BorderRadiusDirectional.only(
+ topStart: style.borderRadius.sm.topLeft,
+ bottomStart: style.borderRadius.sm.bottomLeft,
+ ),
+ border: BorderDirectional(
+ top: BorderSide(color: colors.disable(colors.border), width: style.borderWidth),
+ bottom: BorderSide(color: colors.disable(colors.border), width: style.borderWidth),
+ start: BorderSide(color: colors.disable(colors.border), width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ [.disabled.and(.end)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ borderRadius: BorderRadiusDirectional.only(
+ topEnd: style.borderRadius.sm.topRight,
+ bottomEnd: style.borderRadius.sm.bottomRight,
+ ),
+ border: Border.all(color: colors.disable(colors.border), width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ [.disabled.and(.start).and(.end)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ borderRadius: style.borderRadius.sm,
+ border: Border.all(color: colors.disable(colors.border), width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ // --- error ---
+ [.error]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ border: BorderDirectional(
+ top: BorderSide(color: colors.error, width: style.borderWidth),
+ bottom: BorderSide(color: colors.error, width: style.borderWidth),
+ start: BorderSide(color: colors.error, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.error.and(.start)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ borderRadius: BorderRadiusDirectional.only(
+ topStart: style.borderRadius.sm.topLeft,
+ bottomStart: style.borderRadius.sm.bottomLeft,
+ ),
+ border: BorderDirectional(
+ top: BorderSide(color: colors.error, width: style.borderWidth),
+ bottom: BorderSide(color: colors.error, width: style.borderWidth),
+ start: BorderSide(color: colors.error, width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.error.and(.end)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ borderRadius: BorderRadiusDirectional.only(
+ topEnd: style.borderRadius.sm.topRight,
+ bottomEnd: style.borderRadius.sm.bottomRight,
+ ),
+ border: Border.all(color: colors.error, width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ [.error.and(.start).and(.end)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.card,
+ borderRadius: style.borderRadius.sm,
+ border: Border.all(color: colors.error, width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.foreground),
+ ),
+ // --- error + disabled ---
+ [.error.and(.disabled)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ border: BorderDirectional(
+ top: BorderSide(color: colors.disable(colors.error), width: style.borderWidth),
+ bottom: BorderSide(color: colors.disable(colors.error), width: style.borderWidth),
+ start: BorderSide(color: colors.disable(colors.error), width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ [.error.and(.disabled).and(.start)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ borderRadius: BorderRadiusDirectional.only(
+ topStart: style.borderRadius.sm.topLeft,
+ bottomStart: style.borderRadius.sm.bottomLeft,
+ ),
+ border: BorderDirectional(
+ top: BorderSide(color: colors.disable(colors.error), width: style.borderWidth),
+ bottom: BorderSide(color: colors.disable(colors.error), width: style.borderWidth),
+ start: BorderSide(color: colors.disable(colors.error), width: style.borderWidth),
+ ),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ [.error.and(.disabled).and(.end)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ borderRadius: BorderRadiusDirectional.only(
+ topEnd: style.borderRadius.sm.topRight,
+ bottomEnd: style.borderRadius.sm.bottomRight,
+ ),
+ border: Border.all(color: colors.disable(colors.error), width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ [.error.and(.disabled).and(.start).and(.end)]: FOtpFieldItemStyle(
+ decoration: BoxDecoration(
+ color: colors.disable(colors.card),
+ borderRadius: style.borderRadius.sm,
+ border: Border.all(color: colors.disable(colors.error), width: style.borderWidth),
+ ),
+ contentTextStyle: typography.sm.copyWith(color: colors.disable(colors.foreground)),
+ ),
+ },
+ ),
+ );
+}
+
+/// The style of an individual item in an [FOtpField].
+class FOtpFieldItemStyle with Diagnosticable, _$FOtpFieldItemStyleFunctions {
+ /// The decoration.
+ @override
+ final Decoration decoration;
+
+ /// The content's [TextStyle].
+ @override
+ final TextStyle contentTextStyle;
+
+ /// Creates a [FOtpFieldItemStyle].
+ const FOtpFieldItemStyle({required this.decoration, required this.contentTextStyle});
+}
diff --git a/forui/lib/src/widgets/otp_field/otp_item.dart b/forui/lib/src/widgets/otp_field/otp_item.dart
new file mode 100644
index 000000000..1ba05172f
--- /dev/null
+++ b/forui/lib/src/widgets/otp_field/otp_item.dart
@@ -0,0 +1,145 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/foundation/debug.dart';
+import 'package:forui/src/widgets/otp_field/caret.dart';
+
+/// A marker interface which denotes that mixed-in widgets are items in an OTP field.
+mixin FOtpItemMixin on Widget {}
+
+/// Provides the state of an individual item in an [FOtpField] to its descendants.
+class FOtpItemScope extends InheritedWidget {
+ /// Returns the [FOtpItemScope] from the enclosing [FOtpField].
+ static FOtpItemScope of(BuildContext context) {
+ assert(debugCheckHasAncestor('$FOtpField', context));
+ return context.dependOnInheritedWidgetOfExactType()!;
+ }
+
+ /// The character to display, or null if the item is empty.
+ final String? character;
+
+ /// Whether this item is focused (i.e. the caret should be shown).
+ final bool focused;
+
+ /// Whether this item is the first in its group (e.g. the first item or the first item after a divider).
+ final bool start;
+
+ /// Whether this item is the last in its group (e.g. the last item or the last item before a divider).
+ final bool end;
+
+ /// Creates an [FOtpItemScope].
+ const FOtpItemScope({
+ required this.character,
+ required this.focused,
+ required this.start,
+ required this.end,
+ required super.child,
+ super.key,
+ });
+
+ @override
+ bool updateShouldNotify(FOtpItemScope old) =>
+ character != old.character || focused != old.focused || start != old.start || end != old.end;
+
+ @override
+ void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+ super.debugFillProperties(properties);
+ properties
+ ..add(StringProperty('character', character))
+ ..add(FlagProperty('focused', value: focused, ifTrue: 'focused'))
+ ..add(FlagProperty('start', value: start, ifTrue: 'start'))
+ ..add(FlagProperty('end', value: end, ifTrue: 'end'));
+ }
+}
+
+/// An item in an [FOtpField].
+class FOtpItem extends StatefulWidget with FOtpItemMixin {
+ /// Creates an [FOtpItem].
+ const FOtpItem({super.key});
+
+ @override
+ State createState() => _FOtpItemState();
+}
+
+class _FOtpItemState extends State {
+ late FOtpFieldStyle _style;
+ late FPlatformVariant _platform;
+ late String? _character;
+ late bool _focused;
+ late FOtpFieldItemStyle _itemStyle;
+ late double _cursorHeight;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final FOtpFieldScope(:style, :variants) = FOtpFieldScope.of(context);
+ final FOtpItemScope(:character, :focused, :start, :end) = FOtpItemScope.of(context);
+
+ _style = style;
+ _platform = context.platformVariant;
+ _character = character;
+ _focused = focused;
+
+ final itemVariants = {
+ ...variants.difference({FTextFieldVariant.focused}),
+ if (start) FOtpFieldItemVariant.start,
+ if (end) FOtpFieldItemVariant.end,
+ if (focused) FOtpFieldItemVariant.focused,
+ };
+
+ _itemStyle = style.itemStyles.resolve(itemVariants);
+
+ final painter = TextPainter(
+ text: TextSpan(text: ' ', style: _itemStyle.contentTextStyle),
+ textDirection: .ltr,
+ )..layout();
+
+ _cursorHeight = painter.preferredLineHeight + (_platform == .iOS || _platform == .macOS ? 2 : -4);
+
+ painter.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => DecoratedBox(
+ // Adjacent items cause double borders. This is typically avoided by painting only the right border, but focused
+ // items are an exception since one or many items can be focused at once.
+ decoration: _itemStyle.decoration,
+ child: SizedBox.fromSize(
+ size: _style.itemSize,
+ child: switch (_character) {
+ final character? => Center(child: Text(character, style: _itemStyle.contentTextStyle)),
+ _ when _focused => Center(
+ child: Caret(
+ color: _style.cursorColor,
+ width: _style.cursorWidth,
+ height: _cursorHeight,
+ cursorOpacityAnimates: _style.cursorOpacityAnimates ?? (_platform == .iOS || _platform == .macOS),
+ ),
+ ),
+ _ => null,
+ },
+ ),
+ );
+}
+
+/// A divider between groups of items in an [FOtpField].
+///
+/// Does not mix in [FOtpItemMixin], so [FOtpController] treats it as a visual separator and uses it to determine
+/// `start`/`end` boundaries on adjacent [FOtpItem]s.
+class FOtpDivider extends StatelessWidget {
+ /// Creates an [FOtpDivider].
+ const FOtpDivider({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final FOtpFieldScope(:style, :variants) = FOtpFieldScope.of(context);
+ return Padding(
+ padding: style.dividerPadding,
+ child: SizedBox.fromSize(
+ size: style.dividerSize,
+ child: ColoredBox(color: style.dividerColor.resolve(variants)),
+ ),
+ );
+ }
+}
diff --git a/forui/lib/src/widgets/popover_menu/popover_menu.dart b/forui/lib/src/widgets/popover_menu/popover_menu.dart
index 3a5aea6b2..a3fb2b340 100644
--- a/forui/lib/src/widgets/popover_menu/popover_menu.dart
+++ b/forui/lib/src/widgets/popover_menu/popover_menu.dart
@@ -19,8 +19,6 @@ class PopoverMenuScope extends InheritedWidget {
final Object? groupId;
- final bool hover;
-
final ValueNotifier active;
const PopoverMenuScope({
@@ -28,13 +26,11 @@ class PopoverMenuScope extends InheritedWidget {
required this.groupId,
required this.active,
required super.child,
- this.hover = false,
super.key,
});
@override
- bool updateShouldNotify(PopoverMenuScope old) =>
- style != old.style || groupId != old.groupId || hover != old.hover || active != old.active;
+ bool updateShouldNotify(PopoverMenuScope old) => style != old.style || groupId != old.groupId || active != old.active;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@@ -42,7 +38,6 @@ class PopoverMenuScope extends InheritedWidget {
properties
..add(DiagnosticsProperty('style', style))
..add(DiagnosticsProperty('groupId', groupId))
- ..add(FlagProperty('hover', value: hover, ifTrue: 'hover'))
..add(DiagnosticsProperty('active', active));
}
}
@@ -187,10 +182,6 @@ class FPopoverMenu extends StatefulWidget {
/// Defaults to true.
final bool useViewInsets;
- /// Whether submenus are shown when hovering over an item. Defaults to true on desktop platforms and false on touch
- /// platforms.
- final bool? hover;
-
/// {@macro forui.widgets.FPopover.builder}
final ValueWidgetBuilder builder;
@@ -244,7 +235,6 @@ class FPopoverMenu extends StatefulWidget {
this.traversalEdgeBehavior,
this.useViewPadding = true,
this.useViewInsets = true,
- this.hover,
List Function(BuildContext context, FPopoverController controller, List? menu)
menuBuilder =
defaultItemBuilder,
@@ -310,7 +300,6 @@ class FPopoverMenu extends StatefulWidget {
this.traversalEdgeBehavior,
this.useViewPadding = true,
this.useViewInsets = true,
- this.hover,
List Function(BuildContext context, FPopoverController controller, List? menu)
menuBuilder =
defaultTileBuilder,
@@ -372,7 +361,6 @@ class FPopoverMenu extends StatefulWidget {
..add(EnumProperty('traversalEdgeBehavior', traversalEdgeBehavior))
..add(FlagProperty('useViewPadding', value: useViewPadding, ifTrue: 'using view padding'))
..add(FlagProperty('useViewInsets', value: useViewInsets, ifTrue: 'using view insets'))
- ..add(FlagProperty('hover', value: hover, ifTrue: 'hover'))
..add(ObjectFlagProperty.has('builder', builder));
}
}
@@ -396,8 +384,6 @@ class _FPopoverMenuState extends State {
final groupId = widget.hideRegion == .excludeChild
? (widget.groupId ?? scope?.groupId ?? _groupId)
: widget.groupId;
- final hover = widget.hover ?? scope?.hover ?? context.platformVariant.desktop;
-
return FPopover(
control: widget.control,
style: style,
@@ -424,11 +410,10 @@ class _FPopoverMenuState extends State {
style: style,
groupId: groupId,
active: _active,
- hover: hover,
// The default behavior for non-submenu trigger items.
child: FInheritedItemCallbacks(
- onHoverEnter: hover ? () => _active.value = null : null,
- onPress: hover ? null : () => _active.value = null,
+ onHoverEnter: () => _active.value = null,
+ onPress: () => _active.value = null,
// We explicitly wrap this in a `FInheritedItemData` to prevent any ancestor data from accidentally leaking
// into the popover menu's items.
//
diff --git a/forui/lib/src/widgets/popover_menu/submenu_item.dart b/forui/lib/src/widgets/popover_menu/submenu_item.dart
index ffbb7d81b..18f7fe010 100644
--- a/forui/lib/src/widgets/popover_menu/submenu_item.dart
+++ b/forui/lib/src/widgets/popover_menu/submenu_item.dart
@@ -167,9 +167,6 @@ class FSubmenuItem extends StatelessWidget with FItemMixin {
/// Defaults to [FItemDivider.full].
final FItemDivider submenuDivider;
- /// Whether submenus are shown when hovering over an item. Defaults to true on desktop platforms.
- final bool? hover;
-
/// Creates a [FSubmenuItem].
const FSubmenuItem({
required this.title,
@@ -212,7 +209,6 @@ class FSubmenuItem extends StatelessWidget with FItemMixin {
this.submenuTraversalEdgeBehavior,
this.submenuMaxHeight = .infinity,
this.submenuDivider = .full,
- this.hover,
super.key,
});
@@ -254,8 +250,7 @@ class FSubmenuItem extends StatelessWidget with FItemMixin {
..add(ObjectFlagProperty.has('submenuOnFocusChange', submenuOnFocusChange))
..add(EnumProperty('submenuTraversalEdgeBehavior', submenuTraversalEdgeBehavior))
..add(DoubleProperty('submenuMaxHeight', submenuMaxHeight))
- ..add(EnumProperty('submenuDivider', submenuDivider))
- ..add(FlagProperty('hover', value: hover, ifTrue: 'hover'));
+ ..add(EnumProperty('submenuDivider', submenuDivider));
}
@override
@@ -282,7 +277,6 @@ class FSubmenuItem extends StatelessWidget with FItemMixin {
focusNode: submenuFocusNode,
onFocusChange: submenuOnFocusChange,
traversalEdgeBehavior: submenuTraversalEdgeBehavior,
- hover: hover,
menu: submenu,
builder: (context, controller, _) => _Trigger(
controller: controller,
@@ -337,6 +331,7 @@ class _State extends State<_Trigger> {
final Key _key = UniqueKey();
PopoverMenuScope? _scope;
int _monotonic = 0;
+ bool _hovered = false;
@override
void didChangeDependencies() {
@@ -364,8 +359,9 @@ class _State extends State<_Trigger> {
@override
Widget build(BuildContext context) => switch (_scope) {
null => widget.child,
- final scope when scope.hover => FInheritedItemCallbacks(
+ final scope => FInheritedItemCallbacks(
onHoverEnter: () async {
+ _hovered = true;
final token = _monotonic;
await Future.delayed(scope.style.motion.hoverEnterDuration);
if (token == _monotonic && mounted) {
@@ -373,11 +369,14 @@ class _State extends State<_Trigger> {
unawaited(widget.controller.show());
}
},
- onHoverExit: () => _monotonic++,
- child: widget.child,
- ),
- final scope => FInheritedItemCallbacks(
+ onHoverExit: () {
+ _hovered = false;
+ _monotonic++;
+ },
onPress: () {
+ if (_hovered) {
+ return;
+ }
if (scope.active.value == _key) {
scope.active.value = null;
widget.controller.hide();
diff --git a/forui/lib/src/widgets/popover_menu/submenu_tile.dart b/forui/lib/src/widgets/popover_menu/submenu_tile.dart
index 359629f0f..aec75cebe 100644
--- a/forui/lib/src/widgets/popover_menu/submenu_tile.dart
+++ b/forui/lib/src/widgets/popover_menu/submenu_tile.dart
@@ -159,9 +159,6 @@ class FSubmenuTile extends StatelessWidget with FTileMixin {
/// Defaults to [FItemDivider.full].
final FItemDivider submenuDivider;
- /// Whether submenus are shown when hovering over an item. Defaults to true on desktop platforms.
- final bool? hover;
-
/// Creates a [FSubmenuTile].
const FSubmenuTile({
required this.title,
@@ -202,7 +199,6 @@ class FSubmenuTile extends StatelessWidget with FTileMixin {
this.submenuTraversalEdgeBehavior,
this.submenuMaxHeight = .infinity,
this.submenuDivider = .full,
- this.hover,
super.key,
});
@@ -242,8 +238,7 @@ class FSubmenuTile extends StatelessWidget with FTileMixin {
..add(ObjectFlagProperty.has('submenuOnFocusChange', submenuOnFocusChange))
..add(EnumProperty('submenuTraversalEdgeBehavior', submenuTraversalEdgeBehavior))
..add(DoubleProperty('submenuMaxHeight', submenuMaxHeight))
- ..add(EnumProperty('submenuDivider', submenuDivider))
- ..add(FlagProperty('hover', value: hover, ifTrue: 'hover'));
+ ..add(EnumProperty('submenuDivider', submenuDivider));
}
@override
@@ -270,7 +265,6 @@ class FSubmenuTile extends StatelessWidget with FTileMixin {
focusNode: submenuFocusNode,
onFocusChange: submenuOnFocusChange,
traversalEdgeBehavior: submenuTraversalEdgeBehavior,
- hover: hover,
menu: menu,
builder: (context, controller, _) => _Trigger(
controller: controller,
@@ -323,6 +317,7 @@ class _State extends State<_Trigger> {
final Key _key = UniqueKey();
PopoverMenuScope? _scope;
int _monotonic = 0;
+ bool _hovered = false;
@override
void didChangeDependencies() {
@@ -350,8 +345,9 @@ class _State extends State<_Trigger> {
@override
Widget build(BuildContext context) => switch (_scope) {
null => widget.child,
- final scope when scope.hover => FInheritedItemCallbacks(
+ final scope => FInheritedItemCallbacks(
onHoverEnter: () async {
+ _hovered = true;
final token = _monotonic;
await Future.delayed(scope.style.motion.hoverEnterDuration);
if (token == _monotonic && mounted) {
@@ -359,11 +355,14 @@ class _State extends State<_Trigger> {
unawaited(widget.controller.show());
}
},
- onHoverExit: () => _monotonic++,
- child: widget.child,
- ),
- final scope => FInheritedItemCallbacks(
+ onHoverExit: () {
+ _hovered = false;
+ _monotonic++;
+ },
onPress: () {
+ if (_hovered) {
+ return;
+ }
if (scope.active.value == _key) {
scope.active.value = null;
widget.controller.hide();
diff --git a/forui/lib/src/widgets/text_field/input/input.dart b/forui/lib/src/widgets/text_field/input/input.dart
index 1f4b2eb38..b3d1a7e28 100644
--- a/forui/lib/src/widgets/text_field/input/input.dart
+++ b/forui/lib/src/widgets/text_field/input/input.dart
@@ -274,6 +274,17 @@ class _InputState extends State {
}
}
+ @override
+ void dispose() {
+ widget.controller.removeListener(_handleTextChange);
+ if (widget.statesController == null) {
+ _statesController.dispose();
+ } else {
+ _statesController.removeListener(_handleStatesChange);
+ }
+ super.dispose();
+ }
+
void _handleStatesChange() => SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
@@ -299,6 +310,10 @@ class _InputState extends State {
focusNode: widget.focusNode,
undoController: widget.undoController,
cursorErrorColor: style.cursorColor,
+ cursorWidth: style.cursorWidth,
+ // TextField doesn't apply cursorOpacityAnimates on macOS by default even though it should be.
+ cursorOpacityAnimates:
+ style.cursorOpacityAnimates ?? (context.platformVariant == .iOS || context.platformVariant == .macOS),
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
@@ -457,15 +472,4 @@ class _InputState extends State {
error: widget.error == null ? null : const SizedBox(),
);
}
-
- @override
- void dispose() {
- widget.controller.removeListener(_handleTextChange);
- if (widget.statesController == null) {
- _statesController.dispose();
- } else {
- _statesController.removeListener(_handleStatesChange);
- }
- 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
index 79811d806..68457ee41 100644
--- a/forui/lib/src/widgets/text_field/text_field_style.dart
+++ b/forui/lib/src/widgets/text_field/text_field_style.dart
@@ -1,3 +1,5 @@
+import 'dart:ui';
+
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -115,6 +117,17 @@ class FTextFieldStyle extends FLabelStyle with _$FTextFieldStyleFunctions {
@override
final Color cursorColor;
+ /// The width of the cursor. Defaults to 2.0.
+ ///
+ /// The cursor indicates the current location of text insertion point in the field.
+ @override
+ final double cursorWidth;
+
+ /// Whether the cursor opacity animates. Defaults to the current platform's behavior (true on iOS and macOS, false on
+ /// other platforms).
+ @override
+ final bool? cursorOpacityAnimates;
+
/// The padding surrounding this text field's content.
///
/// Defaults to `const EdgeInsets.symmetric(horizontal: 10, vertical: 9)`.
@@ -184,6 +197,8 @@ class FTextFieldStyle extends FLabelStyle with _$FTextFieldStyleFunctions {
required super.descriptionTextStyle,
required super.errorTextStyle,
this.cursorColor = CupertinoColors.activeBlue,
+ this.cursorWidth = 2.0,
+ this.cursorOpacityAnimates,
this.contentPadding = const .symmetric(horizontal: 10, vertical: 9),
this.clearButtonPadding = const .directional(end: 4),
this.obscureButtonPadding = const .directional(end: 4),
diff --git a/forui/lib/src/widgets/tile/tile_mixin.dart b/forui/lib/src/widgets/tile/tile_mixin.dart
index 2624f8eeb..41045ed84 100644
--- a/forui/lib/src/widgets/tile/tile_mixin.dart
+++ b/forui/lib/src/widgets/tile/tile_mixin.dart
@@ -335,7 +335,6 @@ mixin FTileMixin on Widget {
TraversalEdgeBehavior? submenuTraversalEdgeBehavior,
double submenuMaxHeight = .infinity,
FItemDivider submenuDivider = .full,
- bool? hover,
Key? key,
}) => .new(
title: title,
@@ -376,7 +375,6 @@ mixin FTileMixin on Widget {
submenuTraversalEdgeBehavior: submenuTraversalEdgeBehavior,
submenuMaxHeight: submenuMaxHeight,
submenuDivider: submenuDivider,
- hover: hover,
key: key,
);
diff --git a/forui/lib/widgets/otp_field.dart b/forui/lib/widgets/otp_field.dart
new file mode 100644
index 000000000..813731f59
--- /dev/null
+++ b/forui/lib/widgets/otp_field.dart
@@ -0,0 +1,11 @@
+/// {@category Widgets}
+///
+/// A one-time password input field.
+///
+/// See https://forui.dev/docs/form/otp-field for working examples.
+library forui.widgets.otp_field;
+
+export '../src/widgets/otp_field/otp_field_control.dart' hide InternalFOtpController, InternalFOtpFieldControl;
+export '../src/widgets/otp_field/otp_field.dart';
+export '../src/widgets/otp_field/otp_field_style.dart';
+export '../src/widgets/otp_field/otp_item.dart';
diff --git a/forui/test/golden/macos/otp-field/caret-discrete-blink.png b/forui/test/golden/macos/otp-field/caret-discrete-blink.png
new file mode 100644
index 000000000..a378e13ca
Binary files /dev/null and b/forui/test/golden/macos/otp-field/caret-discrete-blink.png differ
diff --git a/forui/test/golden/macos/otp-field/caret-opacity-animates.png b/forui/test/golden/macos/otp-field/caret-opacity-animates.png
new file mode 100644
index 000000000..6f133b92f
Binary files /dev/null and b/forui/test/golden/macos/otp-field/caret-opacity-animates.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/all-items-focused-divider.png b/forui/test/golden/macos/otp-field/neutral-dark/all-items-focused-divider.png
new file mode 100644
index 000000000..e100eedb5
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/all-items-focused-divider.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/all-items-focused.png b/forui/test/golden/macos/otp-field/neutral-dark/all-items-focused.png
new file mode 100644
index 000000000..abe08dfcc
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/all-items-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/basic.png b/forui/test/golden/macos/otp-field/neutral-dark/basic.png
new file mode 100644
index 000000000..12255842d
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/basic.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/disabled-divider.png b/forui/test/golden/macos/otp-field/neutral-dark/disabled-divider.png
new file mode 100644
index 000000000..c0f199b66
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/disabled-divider.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/disabled-error.png b/forui/test/golden/macos/otp-field/neutral-dark/disabled-error.png
new file mode 100644
index 000000000..471370726
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/disabled-error.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/disabled.png b/forui/test/golden/macos/otp-field/neutral-dark/disabled.png
new file mode 100644
index 000000000..8429e3f95
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/disabled.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/divider.png b/forui/test/golden/macos/otp-field/neutral-dark/divider.png
new file mode 100644
index 000000000..b566c18bd
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/divider.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/error.png b/forui/test/golden/macos/otp-field/neutral-dark/error.png
new file mode 100644
index 000000000..345670bc2
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/error.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/many-items-focused.png b/forui/test/golden/macos/otp-field/neutral-dark/many-items-focused.png
new file mode 100644
index 000000000..4ea2a8570
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/many-items-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/rtl.png b/forui/test/golden/macos/otp-field/neutral-dark/rtl.png
new file mode 100644
index 000000000..a1e71fd01
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/rtl.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/single-empty-item-focused.png b/forui/test/golden/macos/otp-field/neutral-dark/single-empty-item-focused.png
new file mode 100644
index 000000000..d2715cee0
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/single-empty-item-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/single-filled-item-focused.png b/forui/test/golden/macos/otp-field/neutral-dark/single-filled-item-focused.png
new file mode 100644
index 000000000..81ce5e86e
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/single-filled-item-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/single-item.png b/forui/test/golden/macos/otp-field/neutral-dark/single-item.png
new file mode 100644
index 000000000..b8b86950b
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/single-item.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/with-label-and-description.png b/forui/test/golden/macos/otp-field/neutral-dark/with-label-and-description.png
new file mode 100644
index 000000000..cabf17d2f
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/with-label-and-description.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-dark/with-label-description-and-error.png b/forui/test/golden/macos/otp-field/neutral-dark/with-label-description-and-error.png
new file mode 100644
index 000000000..71d48cc0c
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-dark/with-label-description-and-error.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/all-items-focused-divider.png b/forui/test/golden/macos/otp-field/neutral-light/all-items-focused-divider.png
new file mode 100644
index 000000000..bb02c14d2
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/all-items-focused-divider.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/all-items-focused.png b/forui/test/golden/macos/otp-field/neutral-light/all-items-focused.png
new file mode 100644
index 000000000..58db86d6f
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/all-items-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/basic.png b/forui/test/golden/macos/otp-field/neutral-light/basic.png
new file mode 100644
index 000000000..630a750b4
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/basic.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/disabled-divider.png b/forui/test/golden/macos/otp-field/neutral-light/disabled-divider.png
new file mode 100644
index 000000000..dccbeb454
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/disabled-divider.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/disabled-error.png b/forui/test/golden/macos/otp-field/neutral-light/disabled-error.png
new file mode 100644
index 000000000..95d630bb2
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/disabled-error.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/disabled.png b/forui/test/golden/macos/otp-field/neutral-light/disabled.png
new file mode 100644
index 000000000..5372ffe50
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/disabled.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/divider.png b/forui/test/golden/macos/otp-field/neutral-light/divider.png
new file mode 100644
index 000000000..1e8a86344
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/divider.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/error.png b/forui/test/golden/macos/otp-field/neutral-light/error.png
new file mode 100644
index 000000000..7f6a9616f
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/error.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/many-items-focused.png b/forui/test/golden/macos/otp-field/neutral-light/many-items-focused.png
new file mode 100644
index 000000000..579a0750d
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/many-items-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/rtl.png b/forui/test/golden/macos/otp-field/neutral-light/rtl.png
new file mode 100644
index 000000000..9aba8db8f
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/rtl.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/single-empty-item-focused.png b/forui/test/golden/macos/otp-field/neutral-light/single-empty-item-focused.png
new file mode 100644
index 000000000..62a3f7877
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/single-empty-item-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/single-filled-item-focused.png b/forui/test/golden/macos/otp-field/neutral-light/single-filled-item-focused.png
new file mode 100644
index 000000000..648ef9ca7
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/single-filled-item-focused.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/single-item.png b/forui/test/golden/macos/otp-field/neutral-light/single-item.png
new file mode 100644
index 000000000..103f13452
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/single-item.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/with-label-and-description.png b/forui/test/golden/macos/otp-field/neutral-light/with-label-and-description.png
new file mode 100644
index 000000000..4a9489daa
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/with-label-and-description.png differ
diff --git a/forui/test/golden/macos/otp-field/neutral-light/with-label-description-and-error.png b/forui/test/golden/macos/otp-field/neutral-light/with-label-description-and-error.png
new file mode 100644
index 000000000..811f2f367
Binary files /dev/null and b/forui/test/golden/macos/otp-field/neutral-light/with-label-description-and-error.png differ
diff --git a/forui/test/golden/windows/otp-field/caret-discrete-blink.png b/forui/test/golden/windows/otp-field/caret-discrete-blink.png
new file mode 100644
index 000000000..a378e13ca
Binary files /dev/null and b/forui/test/golden/windows/otp-field/caret-discrete-blink.png differ
diff --git a/forui/test/golden/windows/otp-field/caret-opacity-animates.png b/forui/test/golden/windows/otp-field/caret-opacity-animates.png
new file mode 100644
index 000000000..6f133b92f
Binary files /dev/null and b/forui/test/golden/windows/otp-field/caret-opacity-animates.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/all-items-focused-divider.png b/forui/test/golden/windows/otp-field/neutral-dark/all-items-focused-divider.png
new file mode 100644
index 000000000..008be8749
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/all-items-focused-divider.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/all-items-focused.png b/forui/test/golden/windows/otp-field/neutral-dark/all-items-focused.png
new file mode 100644
index 000000000..735bf0d46
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/all-items-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/basic.png b/forui/test/golden/windows/otp-field/neutral-dark/basic.png
new file mode 100644
index 000000000..12255842d
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/basic.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/disabled-divider.png b/forui/test/golden/windows/otp-field/neutral-dark/disabled-divider.png
new file mode 100644
index 000000000..9118c4ca5
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/disabled-divider.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/disabled-error.png b/forui/test/golden/windows/otp-field/neutral-dark/disabled-error.png
new file mode 100644
index 000000000..222ebacfc
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/disabled-error.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/disabled.png b/forui/test/golden/windows/otp-field/neutral-dark/disabled.png
new file mode 100644
index 000000000..b8e6d2ed7
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/disabled.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/divider.png b/forui/test/golden/windows/otp-field/neutral-dark/divider.png
new file mode 100644
index 000000000..b566c18bd
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/divider.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/error.png b/forui/test/golden/windows/otp-field/neutral-dark/error.png
new file mode 100644
index 000000000..1644e105d
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/error.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/many-items-focused.png b/forui/test/golden/windows/otp-field/neutral-dark/many-items-focused.png
new file mode 100644
index 000000000..b5b7bd88d
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/many-items-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/rtl.png b/forui/test/golden/windows/otp-field/neutral-dark/rtl.png
new file mode 100644
index 000000000..d51b39e93
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/rtl.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/single-empty-item-focused.png b/forui/test/golden/windows/otp-field/neutral-dark/single-empty-item-focused.png
new file mode 100644
index 000000000..e66f7deb2
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/single-empty-item-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/single-filled-item-focused.png b/forui/test/golden/windows/otp-field/neutral-dark/single-filled-item-focused.png
new file mode 100644
index 000000000..01d954aaf
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/single-filled-item-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/single-item.png b/forui/test/golden/windows/otp-field/neutral-dark/single-item.png
new file mode 100644
index 000000000..b8b86950b
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/single-item.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/with-label-and-description.png b/forui/test/golden/windows/otp-field/neutral-dark/with-label-and-description.png
new file mode 100644
index 000000000..ac3b32972
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/with-label-and-description.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-dark/with-label-description-and-error.png b/forui/test/golden/windows/otp-field/neutral-dark/with-label-description-and-error.png
new file mode 100644
index 000000000..fe174a6c5
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-dark/with-label-description-and-error.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/all-items-focused-divider.png b/forui/test/golden/windows/otp-field/neutral-light/all-items-focused-divider.png
new file mode 100644
index 000000000..063632cef
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/all-items-focused-divider.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/all-items-focused.png b/forui/test/golden/windows/otp-field/neutral-light/all-items-focused.png
new file mode 100644
index 000000000..c67ae1943
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/all-items-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/basic.png b/forui/test/golden/windows/otp-field/neutral-light/basic.png
new file mode 100644
index 000000000..630a750b4
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/basic.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/disabled-divider.png b/forui/test/golden/windows/otp-field/neutral-light/disabled-divider.png
new file mode 100644
index 000000000..ec9920832
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/disabled-divider.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/disabled-error.png b/forui/test/golden/windows/otp-field/neutral-light/disabled-error.png
new file mode 100644
index 000000000..f8def0377
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/disabled-error.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/disabled.png b/forui/test/golden/windows/otp-field/neutral-light/disabled.png
new file mode 100644
index 000000000..c40da2da8
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/disabled.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/divider.png b/forui/test/golden/windows/otp-field/neutral-light/divider.png
new file mode 100644
index 000000000..1e8a86344
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/divider.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/error.png b/forui/test/golden/windows/otp-field/neutral-light/error.png
new file mode 100644
index 000000000..9fec4e125
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/error.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/many-items-focused.png b/forui/test/golden/windows/otp-field/neutral-light/many-items-focused.png
new file mode 100644
index 000000000..90a7b4e9d
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/many-items-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/rtl.png b/forui/test/golden/windows/otp-field/neutral-light/rtl.png
new file mode 100644
index 000000000..e614cd218
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/rtl.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/single-empty-item-focused.png b/forui/test/golden/windows/otp-field/neutral-light/single-empty-item-focused.png
new file mode 100644
index 000000000..9b3d1ca0c
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/single-empty-item-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/single-filled-item-focused.png b/forui/test/golden/windows/otp-field/neutral-light/single-filled-item-focused.png
new file mode 100644
index 000000000..40e8dc2f2
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/single-filled-item-focused.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/single-item.png b/forui/test/golden/windows/otp-field/neutral-light/single-item.png
new file mode 100644
index 000000000..103f13452
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/single-item.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/with-label-and-description.png b/forui/test/golden/windows/otp-field/neutral-light/with-label-and-description.png
new file mode 100644
index 000000000..7b5d46968
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/with-label-and-description.png differ
diff --git a/forui/test/golden/windows/otp-field/neutral-light/with-label-description-and-error.png b/forui/test/golden/windows/otp-field/neutral-light/with-label-description-and-error.png
new file mode 100644
index 000000000..85f0249c5
Binary files /dev/null and b/forui/test/golden/windows/otp-field/neutral-light/with-label-description-and-error.png differ
diff --git a/forui/test/src/widgets/otp_field/caret_golden_test.dart b/forui/test/src/widgets/otp_field/caret_golden_test.dart
new file mode 100644
index 000000000..391ac3ddb
--- /dev/null
+++ b/forui/test/src/widgets/otp_field/caret_golden_test.dart
@@ -0,0 +1,25 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/src/widgets/otp_field/caret.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ for (final (name, cursorOpacityAnimates) in [('caret-opacity-animates', true), ('caret-discrete-blink', false)]) {
+ testWidgets(name, (tester) async {
+ final sheet = autoDispose(AnimationSheetBuilder(frameSize: const Size(200, 100)));
+
+ await tester.pumpFrames(
+ sheet.record(
+ TestScaffold.app(
+ child: Caret(color: Colors.black, width: 2, height: 20, cursorOpacityAnimates: cursorOpacityAnimates),
+ ),
+ ),
+ const Duration(milliseconds: 1000),
+ );
+
+ await expectLater(sheet.collate(10), matchesGoldenFile('otp-field/$name.png'));
+ });
+ }
+}
diff --git a/forui/test/src/widgets/otp_field/otp_field_control_test.dart b/forui/test/src/widgets/otp_field/otp_field_control_test.dart
new file mode 100644
index 000000000..e22f1bf12
--- /dev/null
+++ b/forui/test/src/widgets/otp_field/otp_field_control_test.dart
@@ -0,0 +1,271 @@
+import 'package:flutter/widgets.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import 'package:forui/src/widgets/otp_field/otp_field_control.dart';
+
+void main() {
+ late FOtpController controller;
+
+ setUp(() {
+ controller = FOtpController();
+ });
+
+ tearDown(() {
+ controller.dispose();
+ });
+
+ group('FOtpController()', () {
+ test('defaults', () {
+ expect(controller.children.length, 6);
+ expect(controller.text, '');
+ expect(controller.focused, 0);
+ });
+
+ test('children with dividers', () {
+ controller.dispose();
+ controller = FOtpController(children: const [FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem()]);
+
+ expect(controller.children.length, 4);
+ expect(controller.focused, 0);
+ });
+ });
+
+ group('traverse empty', () {
+ test('forward does nothing', () {
+ controller.traverse(forward: true);
+
+ expect(controller.selection, const TextSelection.collapsed(offset: 0));
+ expect(controller.focused, 0);
+ });
+
+ test('backward does nothing', () {
+ controller.traverse(forward: false);
+
+ expect(controller.selection, const TextSelection.collapsed(offset: 0));
+ expect(controller.focused, 0);
+ });
+ });
+
+ group('traverse', () {
+ setUp(() {
+ controller.value = const TextEditingValue(text: '123', selection: .collapsed(offset: 3));
+ });
+
+ test('forward from collapsed', () {
+ // At offset 1 (middle), value setter expands to [1, 2].
+ controller
+ ..value = controller.value.copyWith(selection: const .collapsed(offset: 1))
+ ..traverse(forward: true);
+
+ // Traverse moves to offset 2, value setter expands middle selection to [2, 3].
+ expect(controller.selection, const TextSelection(baseOffset: 2, extentOffset: 3));
+ expect(controller.focused, 2);
+ });
+
+ test('backward from collapsed', () {
+ controller
+ ..value = controller.value.copyWith(selection: const .collapsed(offset: 1))
+ ..traverse(forward: false);
+
+ // Traverse moves to offset 0, value setter expands middle selection to [0, 1].
+ expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 1));
+ expect(controller.focused, 0);
+ });
+
+ test('forward clamped at text.length', () {
+ controller.traverse(forward: true);
+
+ // At text.length, value setter passes through as collapsed.
+ expect(controller.selection, const TextSelection.collapsed(offset: 3));
+ expect(controller.focused, 3);
+ });
+
+ test('backward clamped at 0', () {
+ controller
+ ..value = controller.value.copyWith(selection: const .collapsed(offset: 0))
+ ..traverse(forward: false);
+
+ // At offset 0, value setter expands middle selection to [0, 1].
+ expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 1));
+ expect(controller.focused, 0);
+ });
+
+ test('forward from expanded selection', () {
+ controller
+ ..value = controller.value.copyWith(selection: const TextSelection(baseOffset: 0, extentOffset: 2))
+ ..traverse(forward: true);
+
+ // Collapses to end (2), value setter expands middle selection to [2, 3].
+ expect(controller.selection, const TextSelection(baseOffset: 2, extentOffset: 3));
+ expect(controller.focused, 2);
+ });
+
+ test('backward from expanded selection', () {
+ controller
+ ..value = controller.value.copyWith(selection: const TextSelection(baseOffset: 1, extentOffset: 3))
+ ..traverse(forward: false);
+
+ // Collapses to max(start - 1, 0) = 0, value setter expands to [0, 1].
+ expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 1));
+ expect(controller.focused, 0);
+ });
+
+ test('backward from expanded selection at start', () {
+ controller
+ ..value = controller.value.copyWith(selection: const TextSelection(baseOffset: 0, extentOffset: 2))
+ ..traverse(forward: false);
+
+ // Collapses to max(0 - 1, 0) = 0, value setter expands to [0, 1].
+ expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 1));
+ expect(controller.focused, 0);
+ });
+ });
+
+ group('value', () {
+ test('no-op when setting same value', () {
+ int count = 0;
+ controller
+ ..addListener(() => count++)
+ ..value = controller.value;
+
+ expect(count, 0);
+ });
+
+ test('truncates text longer than length', () {
+ controller.value = const TextEditingValue(text: '12345678', selection: .collapsed(offset: 8));
+
+ expect(controller.text, '123456');
+ expect(controller.selection, const TextSelection.collapsed(offset: 6));
+ expect(controller.focused, 5);
+ });
+
+ test('truncates text longer than length when partially filled', () {
+ controller
+ ..value = const TextEditingValue(text: '123', selection: .collapsed(offset: 3))
+ ..value = const TextEditingValue(text: '45678910', selection: .collapsed(offset: 8));
+
+ expect(controller.text, '456789');
+ expect(controller.selection, const TextSelection.collapsed(offset: 6));
+ expect(controller.focused, 5);
+ });
+
+ test('does not truncate text at length', () {
+ controller.value = const TextEditingValue(text: '123456', selection: .collapsed(offset: 6));
+
+ expect(controller.text, '123456');
+ expect(controller.focused, 5);
+ });
+
+ test('truncates grapheme clusters', () {
+ // Each flag emoji is a single grapheme cluster but multiple code units.
+ controller.dispose();
+ controller = FOtpController(children: [const FOtpItem(), const FOtpItem()])
+ ..value = const TextEditingValue(
+ text: 'πΊπΈπ¬π§π«π·',
+ selection: .collapsed(offset: 'πΊπΈπ¬π§π«π·'.length),
+ );
+
+ expect(controller.text, 'πΊπΈπ¬π§');
+ expect(controller.focused, 1);
+ });
+
+ test('focused clamped to length - 1', () {
+ controller.value = const TextEditingValue(text: '123456', selection: .collapsed(offset: 6));
+
+ expect(controller.focused, 5);
+ });
+
+ test('first-item affinity workaround', () {
+ controller.value = const TextEditingValue(
+ text: '12',
+ selection: .collapsed(offset: 1, affinity: .upstream),
+ );
+
+ expect(controller.focused, 0);
+ });
+
+ test('middle item deletion selects previous item', () {
+ controller
+ // Tap item at index 2 β value setter expands selection to [2, 3].
+ ..value = const TextEditingValue(text: '123456', selection: TextSelection(baseOffset: 2, extentOffset: 3))
+ // Backspace deletes '3', framework sends cursor at offset 2.
+ ..value = const TextEditingValue(text: '12456', selection: .collapsed(offset: 2));
+
+ expect(controller.text, '12456');
+ expect(controller.selection, const TextSelection(baseOffset: 1, extentOffset: 2));
+ });
+
+ test('middle item deletion at offset 0 clamps base to 0', () {
+ controller
+ // Select first item β value setter expands selection to [0, 1].
+ ..value = const TextEditingValue(text: '123456', selection: TextSelection(baseOffset: 0, extentOffset: 1))
+ // Backspace deletes '1', framework sends cursor at offset 0.
+ ..value = const TextEditingValue(text: '23456', selection: .collapsed(offset: 0));
+
+ expect(controller.text, '23456');
+ expect(controller.selection, const TextSelection.collapsed(offset: 0));
+ expect(controller.focused, 0);
+ });
+
+ test('middle item selects current item for replacement', () {
+ controller
+ ..value = const TextEditingValue(text: '123456', selection: .collapsed(offset: 6))
+ // Same text length, cursor in middle β should select item at cursor for replacement.
+ ..value = const TextEditingValue(text: '123456', selection: .collapsed(offset: 2));
+
+ expect(controller.selection, const TextSelection(baseOffset: 2, extentOffset: 3));
+ expect(controller.focused, 2);
+ });
+
+ test('focuses next empty item after last inserted character', () {
+ controller.value = const TextEditingValue(text: '123', selection: .collapsed(offset: 3));
+
+ expect(controller.text, '123');
+ expect(controller.selection, const TextSelection.collapsed(offset: 3));
+ expect(controller.focused, 3);
+ });
+
+ test('tapping middle item selects it for replacement', () {
+ controller
+ ..value = const TextEditingValue(text: '123456', selection: .collapsed(offset: 6))
+ // Tap item at index 3.
+ ..value = const TextEditingValue(text: '123456', selection: .collapsed(offset: 3));
+
+ expect(controller.selection, const TextSelection(baseOffset: 3, extentOffset: 4));
+ expect(controller.focused, 3);
+ });
+
+ test('replacing middle item then typing again advances focus', () {
+ controller
+ // Tap item 2 β selection [2, 3].
+ ..value = const TextEditingValue(text: '123456', selection: TextSelection(baseOffset: 2, extentOffset: 3))
+ // Type 'X' replaces '3', framework sends cursor at offset 3.
+ ..value = const TextEditingValue(text: '12X456', selection: .collapsed(offset: 3));
+
+ // Selection expands to [3, 4] for next replacement.
+ expect(controller.selection, const TextSelection(baseOffset: 3, extentOffset: 4));
+ expect(controller.focused, 3);
+
+ // Type 'Y' replaces '4', framework sends cursor at offset 4.
+ controller.value = const TextEditingValue(text: '12XY56', selection: .collapsed(offset: 4));
+
+ expect(controller.selection, const TextSelection(baseOffset: 4, extentOffset: 5));
+ expect(controller.focused, 4);
+ });
+
+ test('deleting last filled item retains focus on now-empty item', () {
+ controller
+ // Field has '123', tap last filled item β selection [2, 3].
+ ..value = const TextEditingValue(text: '123', selection: TextSelection(baseOffset: 2, extentOffset: 3))
+ // Backspace deletes '3', framework sends cursor at offset 2.
+ ..value = const TextEditingValue(text: '12', selection: .collapsed(offset: 2));
+
+ // Offset == text.length, so passes through as collapsed. Focus on the now-empty item.
+ expect(controller.text, '12');
+ expect(controller.selection, const TextSelection.collapsed(offset: 2));
+ expect(controller.focused, 2);
+ });
+ });
+}
diff --git a/forui/test/src/widgets/otp_field/otp_field_golden_test.dart b/forui/test/src/widgets/otp_field/otp_field_golden_test.dart
new file mode 100644
index 000000000..a55a2f601
--- /dev/null
+++ b/forui/test/src/widgets/otp_field/otp_field_golden_test.dart
@@ -0,0 +1,294 @@
+import 'package:flutter/material.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:forui/forui.dart';
+import '../../test_scaffold.dart';
+
+void main() {
+ testWidgets('blue screen', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.blue(
+ child: FOtpField(
+ style: TestScaffold.blueScreen.otpFieldStyle,
+ control: const .managed(initial: TextEditingValue(text: '123456')),
+ ),
+ ),
+ );
+
+ await expectBlueScreen();
+ });
+
+ for (final theme in TestScaffold.themes) {
+ testWidgets('basic - ${theme.name}', (tester) async {
+ await tester.pumpWidget(TestScaffold.app(theme: theme.data, child: FOtpField()));
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/basic.png'));
+ });
+
+ testWidgets('single item - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(control: const .managed(children: [FOtpItem()])),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/single-item.png'));
+ });
+
+ testWidgets('divider - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(
+ control: const .managed(
+ children: [FOtpItem(), FOtpItem(), FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem(), FOtpItem()],
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/divider.png'));
+ });
+
+ testWidgets('disabled divider - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(
+ control: const .managed(
+ initial: TextEditingValue(text: '123456'),
+ children: [FOtpItem(), FOtpItem(), FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem(), FOtpItem()],
+ ),
+ enabled: false,
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/disabled-divider.png'));
+ });
+
+ testWidgets('single empty item focused - ${theme.name}', (tester) async {
+ await tester.pumpWidget(TestScaffold.app(theme: theme.data, child: FOtpField(autofocus: true)));
+
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('otp-field/${theme.name}/single-empty-item-focused.png'),
+ );
+ });
+
+ testWidgets('single filled item focused - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(
+ control: const .managed(initial: TextEditingValue(text: '123456')),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byType(FOtpField));
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('otp-field/${theme.name}/single-filled-item-focused.png'),
+ );
+ });
+
+ testWidgets('many items focused - ${theme.name}', (tester) async {
+ final controller = autoDispose(FOtpController());
+
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(control: .managed(controller: controller)),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byType(FOtpField));
+ await tester.pumpAndSettle();
+
+ controller.value = const TextEditingValue(
+ text: '123456',
+ selection: TextSelection(baseOffset: 1, extentOffset: 4),
+ );
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/many-items-focused.png'));
+ });
+
+ testWidgets('all items focused - ${theme.name}', (tester) async {
+ final controller = autoDispose(FOtpController());
+
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(control: .managed(controller: controller)),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byType(FOtpField));
+ await tester.pumpAndSettle();
+
+ controller.value = const TextEditingValue(
+ text: '123456',
+ selection: TextSelection(baseOffset: 0, extentOffset: 6),
+ );
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/all-items-focused.png'));
+ });
+
+ testWidgets('all items focused divider - ${theme.name}', (tester) async {
+ final controller = autoDispose(
+ FOtpController(
+ children: const [FOtpItem(), FOtpItem(), FOtpItem(), FOtpDivider(), FOtpItem(), FOtpItem(), FOtpItem()],
+ ),
+ );
+
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(control: .managed(controller: controller)),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byType(FOtpField));
+ await tester.pumpAndSettle();
+
+ controller.value = const TextEditingValue(
+ text: '123456',
+ selection: TextSelection(baseOffset: 0, extentOffset: 6),
+ );
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('otp-field/${theme.name}/all-items-focused-divider.png'),
+ );
+ });
+
+ testWidgets('disabled - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(
+ control: const .managed(initial: TextEditingValue(text: '123456')),
+ enabled: false,
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/disabled.png'));
+ });
+
+ testWidgets('error - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(
+ control: const .managed(initial: TextEditingValue(text: '123456')),
+ forceErrorText: 'An error has occurred.',
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/error.png'));
+ });
+
+ testWidgets('disabled error - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(
+ control: const .managed(initial: TextEditingValue(text: '123456')),
+ enabled: false,
+ forceErrorText: 'An error has occurred.',
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/disabled-error.png'));
+ });
+
+ testWidgets('with label and description - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(label: const Text('My Label'), description: const Text('Some help text.')),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('otp-field/${theme.name}/with-label-and-description.png'),
+ );
+ });
+
+ testWidgets('with label, description and error - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ child: FOtpField(
+ label: const Text('My Label'),
+ description: const Text('Some help text.'),
+ forceErrorText: 'An error has occurred.',
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(
+ find.byType(TestScaffold),
+ matchesGoldenFile('otp-field/${theme.name}/with-label-description-and-error.png'),
+ );
+ });
+
+ testWidgets('RTL - ${theme.name}', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ theme: theme.data,
+ textDirection: .rtl,
+ child: FOtpField(
+ control: const .managed(initial: TextEditingValue(text: '123456')),
+ label: const Text('My Label'),
+ description: const Text('Some help text.'),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+
+ await expectLater(find.byType(TestScaffold), matchesGoldenFile('otp-field/${theme.name}/rtl.png'));
+ });
+ }
+}
diff --git a/forui/test/src/widgets/popover_menu/popover_menu_golden_test.dart b/forui/test/src/widgets/popover_menu/popover_menu_golden_test.dart
index 2cac1800e..e21356ddc 100644
--- a/forui/test/src/widgets/popover_menu/popover_menu_golden_test.dart
+++ b/forui/test/src/widgets/popover_menu/popover_menu_golden_test.dart
@@ -196,7 +196,6 @@ void main() {
platform: platform,
child: FPopoverMenu(
control: const .managed(initial: true),
- hover: false,
menu: [
.group(
children: [
@@ -237,7 +236,6 @@ void main() {
platform: platform,
child: FPopoverMenu.tiles(
control: const .managed(initial: true),
- hover: false,
menu: [
.group(
children: [
@@ -278,7 +276,6 @@ void main() {
TestScaffold.app(
child: FPopoverMenu(
control: const .managed(initial: true),
- hover: false,
menu: [
.group(
children: [
@@ -309,7 +306,6 @@ void main() {
platform: .macOS,
child: FPopoverMenu(
control: const .managed(initial: true),
- hover: false,
style: .delta(
minWidth: 300,
maxWidth: 300,
@@ -355,7 +351,6 @@ void main() {
textDirection: .rtl,
child: FPopoverMenu(
control: const .managed(initial: true),
- hover: false,
menu: [
.group(
children: [
diff --git a/forui/test/src/widgets/popover_menu/submenu_item_test.dart b/forui/test/src/widgets/popover_menu/submenu_item_test.dart
index 15d52176c..4f698bd89 100644
--- a/forui/test/src/widgets/popover_menu/submenu_item_test.dart
+++ b/forui/test/src/widgets/popover_menu/submenu_item_test.dart
@@ -7,12 +7,11 @@ import '../../test_scaffold.dart';
void main() {
group('FSubmenuItem', () {
- group('tap mode', () {
+ group('tap', () {
testWidgets('tap on submenu item opens submenu', (tester) async {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -46,7 +45,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -82,7 +80,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -128,7 +125,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -166,7 +162,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -203,7 +198,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -237,12 +231,11 @@ void main() {
});
});
- group('hover mode', () {
+ group('hover', () {
testWidgets('hover over submenu item shows submenu', (tester) async {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: true,
menu: [
.group(
children: [
@@ -278,7 +271,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: true,
menu: [
.group(
children: [
@@ -317,7 +309,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: true,
menu: [
.group(
children: [
@@ -366,7 +357,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu(
- hover: true,
menu: [
.group(
children: [
@@ -406,7 +396,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu(
- hover: true,
menu: [
.group(
children: [
@@ -440,6 +429,44 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Email'), findsNothing);
});
+
+ testWidgets('tap on hover-opened submenu item does not close submenu', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ child: FPopoverMenu(
+ menu: [
+ .group(
+ children: [
+ .item(title: const Text('Edit'), onPress: () {}),
+ .submenu(
+ title: const Text('Share'),
+ submenu: [
+ .group(
+ children: [.item(title: const Text('Email'), onPress: () {})],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ builder: (_, controller, _) => FButton(onPress: controller.toggle, child: const Text('Open')),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ final gesture = await tester.createPointerGesture();
+ await gesture.moveTo(tester.getCenter(find.text('Share')));
+ await tester.pump(const Duration(milliseconds: 150));
+ await tester.pumpAndSettle();
+ expect(find.text('Email'), findsOneWidget);
+
+ await tester.tap(find.text('Share'));
+ await tester.pumpAndSettle();
+ expect(find.text('Email'), findsOneWidget);
+ });
});
group('nested submenus', () {
@@ -447,7 +474,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -492,7 +518,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
@@ -542,7 +567,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu(
- hover: false,
menu: [
.group(
children: [
diff --git a/forui/test/src/widgets/popover_menu/submenu_tile_test.dart b/forui/test/src/widgets/popover_menu/submenu_tile_test.dart
index d74eedb8f..1751da61b 100644
--- a/forui/test/src/widgets/popover_menu/submenu_tile_test.dart
+++ b/forui/test/src/widgets/popover_menu/submenu_tile_test.dart
@@ -7,12 +7,11 @@ import '../../test_scaffold.dart';
void main() {
group('FSubmenuTile', () {
- group('tap mode', () {
+ group('tap', () {
testWidgets('tap on submenu tile opens submenu', (tester) async {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -46,7 +45,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -82,7 +80,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -128,7 +125,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -166,7 +162,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -203,7 +198,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -237,12 +231,11 @@ void main() {
});
});
- group('hover mode', () {
+ group('hover', () {
testWidgets('hover over submenu tile shows submenu', (tester) async {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: true,
menu: [
.group(
children: [
@@ -278,7 +271,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: true,
menu: [
.group(
children: [
@@ -317,7 +309,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: true,
menu: [
.group(
children: [
@@ -366,7 +357,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu.tiles(
- hover: true,
menu: [
.group(
children: [
@@ -406,7 +396,6 @@ void main() {
TestScaffold.app(
platform: .macOS,
child: FPopoverMenu.tiles(
- hover: true,
menu: [
.group(
children: [
@@ -440,6 +429,44 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Email'), findsNothing);
});
+
+ testWidgets('tap on hover-opened submenu tile does not close submenu', (tester) async {
+ await tester.pumpWidget(
+ TestScaffold.app(
+ child: FPopoverMenu.tiles(
+ menu: [
+ .group(
+ children: [
+ .tile(title: const Text('Edit'), onPress: () {}),
+ .submenu(
+ title: const Text('Share'),
+ menu: [
+ .group(
+ children: [.tile(title: const Text('Email'), onPress: () {})],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ builder: (_, controller, _) => FButton(onPress: controller.toggle, child: const Text('Open')),
+ ),
+ ),
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ final gesture = await tester.createPointerGesture();
+ await gesture.moveTo(tester.getCenter(find.text('Share')));
+ await tester.pump(const Duration(milliseconds: 150));
+ await tester.pumpAndSettle();
+ expect(find.text('Email'), findsOneWidget);
+
+ await tester.tap(find.text('Share'));
+ await tester.pumpAndSettle();
+ expect(find.text('Email'), findsOneWidget);
+ });
});
group('nested submenus', () {
@@ -447,7 +474,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -492,7 +518,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -542,7 +567,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [
@@ -589,7 +613,6 @@ void main() {
await tester.pumpWidget(
TestScaffold.app(
child: FPopoverMenu.tiles(
- hover: false,
menu: [
.group(
children: [