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: [