From 7958f2ee8e94ca7d979e6ed65254224afd1a6a92 Mon Sep 17 00:00:00 2001 From: Koen Van Looveren Date: Sat, 5 Oct 2024 09:38:57 +0200 Subject: [PATCH] feat: command menu added --- .flutter-plugins-dependencies | 2 +- example/lib/src/app.dart | 14 ++ .../command_menu_library_item.dart | 31 ++++ .../command_menu_library_variant.dart | 72 ++++++++ .../input_field/input_field_library_item.dart | 4 + .../input_field_library_variant.dart | 17 +- .../config/component_library.dart | 2 + .../src/widget/components/component_card.dart | 5 +- lib/impaktfull_ui.dart | 2 + lib/src/components/card/card.dart | 2 +- .../components/command_menu/command_menu.dart | 57 +++++++ .../command_menu/command_menu.describe.dart | 8 + .../command_menu/command_menu_style.dart | 39 +++++ .../command_menu/command_menu_window.dart | 161 ++++++++++++++++++ .../command_menu/commander/commander.dart | 2 + .../src/controller/commander_controller.dart | 39 +++++ .../commander_controller_listener.dart | 9 + .../widget/commander_configurator_widget.dart | 94 ++++++++++ .../components/input_field/input_field.dart | 34 +++- lib/src/theme/asset_theme.dart | 3 +- lib/src/theme/component_theme.dart | 7 + lib/src/theme/theme_default.dart | 9 + 22 files changed, 597 insertions(+), 16 deletions(-) create mode 100644 example/lib/src/component_library/components/command_menu/command_menu_library_item.dart create mode 100644 example/lib/src/component_library/components/command_menu/command_menu_library_variant.dart create mode 100644 lib/src/components/command_menu/command_menu.dart create mode 100644 lib/src/components/command_menu/command_menu.describe.dart create mode 100644 lib/src/components/command_menu/command_menu_style.dart create mode 100644 lib/src/components/command_menu/command_menu_window.dart create mode 100644 lib/src/components/command_menu/commander/commander.dart create mode 100644 lib/src/components/command_menu/commander/src/controller/commander_controller.dart create mode 100644 lib/src/components/command_menu/commander/src/controller/commander_controller_listener.dart create mode 100644 lib/src/components/command_menu/commander/src/widget/commander_configurator_widget.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index a9341052..2a14f30c 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"android":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"macos":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"linux":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"windows":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"web":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","dependencies":[]}]},"dependencyGraph":[{"name":"rive_common","dependencies":[]}],"date_created":"2024-10-04 13:48:26.978666","version":"3.24.1","swift_package_manager_enabled":false} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"android":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"macos":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"linux":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"windows":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","native_build":true,"dependencies":[]}],"web":[{"name":"rive_common","path":"/Users/vanlooverenkoen/.pub-cache/hosted/pub.dev/rive_common-0.4.11/","dependencies":[]}]},"dependencyGraph":[{"name":"rive_common","dependencies":[]}],"date_created":"2024-10-04 15:51:00.531304","version":"3.24.1","swift_package_manager_enabled":false} \ No newline at end of file diff --git a/example/lib/src/app.dart b/example/lib/src/app.dart index 7a4cc0f7..0a235cbb 100644 --- a/example/lib/src/app.dart +++ b/example/lib/src/app.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:impaktfull_ui_2/impaktfull_ui.dart'; import 'package:impaktfull_ui_example/src/screen/home/home_screen.dart'; import 'package:impaktfull_ui_example/src/widget/theme/theme_button.dart'; @@ -21,6 +22,19 @@ class MyAppState extends State { title: 'impaktfull ui', impaktfullUiTheme: ThemeButton.activeTheme, flavorBannerText: kDebugMode ? null : 'Prod', + builder: (context, app) => ImpaktfullUiCommandMenu( + shortcutActivator: const SingleActivator( + LogicalKeyboardKey.space, + meta: true, + control: true, + ), + builder: (context, theme, controller) => CommandMenuWindow( + onCloseWindow: () => controller.hide(), + onInputChanged: (value) {}, + hasBlurredBackground: true, + ), + child: app, + ), // const is disabled here because it would not rebuild when the theme is set again. // ignore: prefer_const_constructors home: HomeScreen(), diff --git a/example/lib/src/component_library/components/command_menu/command_menu_library_item.dart b/example/lib/src/component_library/components/command_menu/command_menu_library_item.dart new file mode 100644 index 00000000..c7b35435 --- /dev/null +++ b/example/lib/src/component_library/components/command_menu/command_menu_library_item.dart @@ -0,0 +1,31 @@ +import 'package:impaktfull_ui_example/src/component_library/components/command_menu/command_menu_library_variant.dart'; +import 'package:impaktfull_ui_example/src/component_library/config/component_library_inputs.dart'; +import 'package:impaktfull_ui_example/src/component_library/config/component_library_item.dart'; +import 'package:impaktfull_ui_example/src/component_library/inputs/component_library_boolean_input.dart'; +import 'package:impaktfull_ui_example/src/component_library/inputs/component_library_string_input.dart'; + +class CommandMenuLibraryItem extends ComponentLibraryItem { + const CommandMenuLibraryItem(); + + @override + String get title => 'ImpaktfullUiCommandMenu'; + + @override + List getComponentVariants() { + return [ + const CommandMenuLibraryVariant(), + ]; + } +} + +class CommandMenuLibraryInputs extends ComponentLibraryInputs { + final ComponentLibraryStringInput input = + ComponentLibraryStringInput('Search value'); + final ComponentLibraryBoolInput blurBackground = + ComponentLibraryBoolInput('Blur background'); + @override + List buildInputItems() => [ + input, + blurBackground, + ]; +} diff --git a/example/lib/src/component_library/components/command_menu/command_menu_library_variant.dart b/example/lib/src/component_library/components/command_menu/command_menu_library_variant.dart new file mode 100644 index 00000000..da048d2b --- /dev/null +++ b/example/lib/src/component_library/components/command_menu/command_menu_library_variant.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:impaktfull_ui_2/impaktfull_ui.dart'; +import 'package:impaktfull_ui_example/src/component_library/components/command_menu/command_menu_library_item.dart'; +import 'package:impaktfull_ui_example/src/component_library/config/component_library_item.dart'; + +class CommandMenuLibraryVariant + extends ComponentLibraryVariant { + const CommandMenuLibraryVariant(); + + @override + String get title => 'Default'; + + @override + List build( + BuildContext context, CommandMenuLibraryPrimaryInputs inputs) { + return [ + ImpaktfullUiCommandMenu( + shortcutActivator: const SingleActivator( + LogicalKeyboardKey.keyK, + meta: true, + ), + builder: (context, theme, controller) => CommandMenuWindow( + onCloseWindow: () => controller.hide(), + onInputChanged: (value) => inputs.input.updateState(value), + hasBlurredBackground: inputs.blurBackground.value ?? false, + padding: EdgeInsets.zero, + marginInputField: const EdgeInsetsDirectional.all(16), + bottomBuilder: (context) { + if (inputs.input.value == null || inputs.input.value!.isEmpty) { + return null; + } + return ImpaktfullUiListView.builder( + items: List.generate(100, (i) => '${inputs.input.value}: $i'), + itemBuilder: (context, item, index) => ImpaktfullUiListItem( + title: item, + onTap: () { + ImpaktfullUiNotification.show(title: 'On $item tapped'); + controller.hide(); + }, + ), + noDataLabel: 'No data found', + ); + }, + ), + child: Focus( + autofocus: true, + child: Container( + color: theme.colors.accent, + width: double.infinity, + height: 500, + padding: const EdgeInsets.all(16), + alignment: Alignment.center, + child: Center( + child: ImpaktfullUiInputField( + value: '', + hint: + 'If you are inside this input field, you can use the keyboard shortcut to open the command menu (cmd +k or win +)', + onChanged: (value) {}, + ), + ), + ), + ), + ), + ]; + } + + @override + CommandMenuLibraryPrimaryInputs inputs() => CommandMenuLibraryPrimaryInputs(); +} + +class CommandMenuLibraryPrimaryInputs extends CommandMenuLibraryInputs {} diff --git a/example/lib/src/component_library/components/input_field/input_field_library_item.dart b/example/lib/src/component_library/components/input_field/input_field_library_item.dart index d310c919..5a649113 100644 --- a/example/lib/src/component_library/components/input_field/input_field_library_item.dart +++ b/example/lib/src/component_library/components/input_field/input_field_library_item.dart @@ -1,5 +1,6 @@ import 'package:impaktfull_ui_example/src/component_library/config/component_library_inputs.dart'; import 'package:impaktfull_ui_example/src/component_library/config/component_library_item.dart'; +import 'package:impaktfull_ui_example/src/component_library/inputs/component_library_boolean_input.dart'; import 'package:impaktfull_ui_example/src/component_library/inputs/component_library_icon_input.dart'; import 'package:impaktfull_ui_example/src/component_library/inputs/component_library_string_input.dart'; import 'package:impaktfull_ui_example/src/component_library/components/input_field/input_field_library_variant.dart'; @@ -26,6 +27,8 @@ class InputLibraryInputs extends ComponentLibraryInputs { final ComponentLibraryStringInput hint = ComponentLibraryStringInput('Hint'); final ComponentLibraryStringInput value = ComponentLibraryStringInput('Value'); + final ComponentLibraryBoolInput showTrailingAction = + ComponentLibraryBoolInput('Show trailing action'); @override List buildInputItems() => [ @@ -33,5 +36,6 @@ class InputLibraryInputs extends ComponentLibraryInputs { leadingIcon, hint, value, + showTrailingAction, ]; } diff --git a/example/lib/src/component_library/components/input_field/input_field_library_variant.dart b/example/lib/src/component_library/components/input_field/input_field_library_variant.dart index 3e553912..af18b088 100644 --- a/example/lib/src/component_library/components/input_field/input_field_library_variant.dart +++ b/example/lib/src/component_library/components/input_field/input_field_library_variant.dart @@ -17,6 +17,7 @@ class InputFieldVariant final hint = inputs.hint.value ?? '{hint}'; final value = inputs.value.value; final leadingIcon = inputs.leadingIcon.value; + final showTrailingAction = inputs.showTrailingAction.value ?? false; return [ ImpaktfullUiInputField( leadingIcon: @@ -25,13 +26,15 @@ class InputFieldVariant hint: hint, value: value, onChanged: inputs.value.updateState, - trailingAction: ImpaktfullUiInputFieldAction( - label: 'Copy', - asset: theme.assets.icons.copy, - onTap: () => ImpaktfullUiNotification.show( - title: 'Copied to clipboard', - ), - ), + trailingAction: showTrailingAction + ? ImpaktfullUiInputFieldAction( + label: 'Copy', + asset: theme.assets.icons.copy, + onTap: () => ImpaktfullUiNotification.show( + title: 'Copied to clipboard', + ), + ) + : null, ), ]; } diff --git a/example/lib/src/component_library/config/component_library.dart b/example/lib/src/component_library/config/component_library.dart index 4adc213b..71f4be12 100644 --- a/example/lib/src/component_library/config/component_library.dart +++ b/example/lib/src/component_library/config/component_library.dart @@ -9,6 +9,7 @@ import 'package:impaktfull_ui_example/src/component_library/components/card/card import 'package:impaktfull_ui_example/src/component_library/components/checkbox/checkbox_library_item.dart'; import 'package:impaktfull_ui_example/src/component_library/components/cms_header/cms_header_library_item.dart'; import 'package:impaktfull_ui_example/src/component_library/components/color_picker/color_picker_library_item.dart'; +import 'package:impaktfull_ui_example/src/component_library/components/command_menu/command_menu_library_item.dart'; import 'package:impaktfull_ui_example/src/component_library/components/dropdown/dropdown_library_item.dart'; import 'package:impaktfull_ui_example/src/component_library/components/grid_view/grid_view_library_item.dart'; import 'package:impaktfull_ui_example/src/component_library/components/horizontal_tabs/horizontal_tabs_library_item.dart'; @@ -44,6 +45,7 @@ class ComponentLibrary { const CardLibraryItem(), const CmsHeaderLibraryItem(), const ColorPickerLibraryItem(), + const CommandMenuLibraryItem(), const DropdownLibraryItem(), const GridViewLibraryItem(), const HorizontalTabsLibraryItem(), diff --git a/example/lib/src/widget/components/component_card.dart b/example/lib/src/widget/components/component_card.dart index ef886e9a..f8159c80 100644 --- a/example/lib/src/widget/components/component_card.dart +++ b/example/lib/src/widget/components/component_card.dart @@ -34,7 +34,10 @@ class ComponentCard extends StatelessWidget { padding: const EdgeInsets.all(16), alignment: Alignment.center, child: IgnorePointer( - child: correctChild, + child: Focus( + descendantsAreFocusable: false, + child: correctChild, + ), ), ), ), diff --git a/lib/impaktfull_ui.dart b/lib/impaktfull_ui.dart index f9919489..7ca80855 100644 --- a/lib/impaktfull_ui.dart +++ b/lib/impaktfull_ui.dart @@ -11,6 +11,8 @@ export 'src/components/card/card.dart'; export 'src/components/checkbox/checkbox.dart'; export 'src/components/cms_header/cms_header.dart'; export 'src/components/color_picker/color_picker.dart'; +export 'src/components/command_menu/command_menu.dart'; +export 'src/components/command_menu/command_menu_window.dart'; export 'src/components/divider/divider.dart'; export 'src/components/dropdown/dropdown.dart'; export 'src/components/grid_view/grid_view.dart'; diff --git a/lib/src/components/card/card.dart b/lib/src/components/card/card.dart index 7ec0329a..45f8928a 100644 --- a/lib/src/components/card/card.dart +++ b/lib/src/components/card/card.dart @@ -17,7 +17,7 @@ class ImpaktfullUiCard extends StatefulWidget with ComponentDescriptorMixin { final VoidCallback? onTap; final MouseCursor cursor; final VoidCallback? onFocus; - final EdgeInsets? padding; + final EdgeInsetsGeometry? padding; final BorderRadiusGeometry? borderRadius; final ImpaktfullUiCardTheme? theme; diff --git a/lib/src/components/command_menu/command_menu.dart b/lib/src/components/command_menu/command_menu.dart new file mode 100644 index 00000000..facfbc9d --- /dev/null +++ b/lib/src/components/command_menu/command_menu.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/command_menu_style.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/commander/commander.dart'; +import 'package:impaktfull_ui_2/src/components/theme/theme_component_builder.dart'; +import 'package:impaktfull_ui_2/src/util/descriptor/component_descriptor_mixin.dart'; + +export 'command_menu_style.dart'; + +part 'command_menu.describe.dart'; + +class ImpaktfullUiCommandMenu extends StatefulWidget with ComponentDescriptorMixin { + final ShortcutActivator? shortcutActivator; + final Widget child; + final Widget Function(BuildContext context, ImpaktfullUiCommandMenuTheme theme, CommanderController controller) + builder; + final ImpaktfullUiCommandMenuTheme? theme; + + const ImpaktfullUiCommandMenu({ + required this.child, + required this.builder, + this.shortcutActivator, + this.theme, + super.key, + }); + + @override + State createState() => _ImpaktfullUiCommandMenuState(); + + @override + String describe(BuildContext context) => _describeInstance(context, this); +} + +class _ImpaktfullUiCommandMenuState extends State { + final _commanderController = CommanderController(); + + @override + Widget build(BuildContext context) { + return ImpaktfullUiComponentThemeBuidler( + overrideComponentTheme: widget.theme, + builder: (context, theme, componentTheme) { + final shortcutActivator = widget.shortcutActivator; + if (widget.shortcutActivator == null) return widget.child; + return CommanderConfiguratorWidget( + commanderController: _commanderController, + shortcutActivator: shortcutActivator!, + builder: (context) => CallbackShortcuts( + bindings: { + shortcutActivator: () => _commanderController.hide(), + }, + child: widget.builder(context, componentTheme, _commanderController), + ), + child: widget.child, + ); + }, + ); + } +} diff --git a/lib/src/components/command_menu/command_menu.describe.dart b/lib/src/components/command_menu/command_menu.describe.dart new file mode 100644 index 00000000..77b81e2d --- /dev/null +++ b/lib/src/components/command_menu/command_menu.describe.dart @@ -0,0 +1,8 @@ +part of 'command_menu.dart'; + +String _describeInstance( + BuildContext context, ImpaktfullUiCommandMenu instance) { + final descriptor = ComponentDescriptor(); + descriptor.add('theme', instance.theme); + return descriptor.describe(); +} diff --git a/lib/src/components/command_menu/command_menu_style.dart b/lib/src/components/command_menu/command_menu_style.dart new file mode 100644 index 00000000..83e69d0e --- /dev/null +++ b/lib/src/components/command_menu/command_menu_style.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:impaktfull_ui_2/src/theme/theme.dart'; + +class ImpaktfullUiCommandMenuTheme extends ImpaktfullUiComponentTheme { + final ImpaktfullUiCommandMenuAssetsTheme assets; + final ImpaktfullUiCommandMenuColorTheme colors; + final ImpaktfullUiCommandMenuDimensTheme dimens; + final ImpaktfullUiCommandMenuTextStyleTheme textStyles; + + const ImpaktfullUiCommandMenuTheme({ + required this.assets, + required this.colors, + required this.dimens, + required this.textStyles, + }); + + static ImpaktfullUiCommandMenuTheme of(BuildContext context) => + ImpaktfullUiTheme.of(context).components.commandMenu; +} + +class ImpaktfullUiCommandMenuAssetsTheme { + const ImpaktfullUiCommandMenuAssetsTheme(); +} + +class ImpaktfullUiCommandMenuColorTheme { + const ImpaktfullUiCommandMenuColorTheme(); +} + +class ImpaktfullUiCommandMenuDimensTheme { + final BorderRadiusGeometry windowBorderRadius; + + const ImpaktfullUiCommandMenuDimensTheme({ + required this.windowBorderRadius, + }); +} + +class ImpaktfullUiCommandMenuTextStyleTheme { + const ImpaktfullUiCommandMenuTextStyleTheme(); +} diff --git a/lib/src/components/command_menu/command_menu_window.dart b/lib/src/components/command_menu/command_menu_window.dart new file mode 100644 index 00000000..11506f1b --- /dev/null +++ b/lib/src/components/command_menu/command_menu_window.dart @@ -0,0 +1,161 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:impaktfull_ui_2/src/components/auto_layout/auto_layout.dart'; +import 'package:impaktfull_ui_2/src/components/card/card.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/command_menu.dart'; +import 'package:impaktfull_ui_2/src/components/input_field/input_field.dart'; +import 'package:impaktfull_ui_2/src/components/theme/theme_component_builder.dart'; + +class CommandMenuWindow extends StatefulWidget { + final ValueChanged onInputChanged; + final VoidCallback onCloseWindow; + final bool hasBlurredBackground; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry marginInputField; + final ImpaktfullUiCommandMenuTheme? theme; + final Widget? Function(BuildContext context)? bottomBuilder; + + const CommandMenuWindow({ + required this.onInputChanged, + required this.onCloseWindow, + this.bottomBuilder, + this.padding = const EdgeInsets.all(16), + this.marginInputField = EdgeInsets.zero, + this.theme, + this.hasBlurredBackground = false, + super.key, + }); + + @override + State createState() => _CommandMenuWindowState(); +} + +class _CommandMenuWindowState extends State { + final _textEditingController = TextEditingController(); + final _foucsNode = FocusNode(); + + @override + void dispose() { + _textEditingController.dispose(); + _foucsNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ImpaktfullUiComponentThemeBuidler( + overrideComponentTheme: widget.theme, + builder: (context, theme, componentTheme) => Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (intent) => widget.onCloseWindow(), + ), + }, + child: LayoutBuilder( + builder: (context, constraints) => Stack( + children: [ + if (widget.hasBlurredBackground) ...[ + Positioned.fill( + child: Opacity( + opacity: 1, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 8, + sigmaY: 8, + ), + child: Container(), + ), + ), + ), + ], + Positioned.fill( + child: GestureDetector( + onTap: widget.onCloseWindow, + child: const ColoredBox( + color: Colors.black26, + ), + ), + ), + Positioned.directional( + start: 0, + end: 0, + top: constraints.maxHeight / 3, + bottom: 0, + textDirection: Directionality.of(context), + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.all(16), + child: LayoutBuilder( + builder: (context, constraints) { + final bottomChild = widget.bottomBuilder != null + ? widget.bottomBuilder!(context) + : null; + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(constraints.maxWidth, 800), + maxHeight: min(constraints.maxHeight, 800), + ), + child: GestureDetector( + excludeFromSemantics: true, + onTap: () { + // cancel the close window + }, + child: ImpaktfullUiCard( + padding: widget.padding, + borderRadius: + componentTheme.dimens.windowBorderRadius, + child: ImpaktfullUiAutoLayout.vertical( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Padding( + padding: widget.marginInputField, + child: ImpaktfullUiInputField( + value: '', + autofocus: true, + focusNode: _foucsNode, + onFocusChanged: _onFocusChanged, + controller: _textEditingController, + onChanged: _onInputChanged, + ), + ), + if (bottomChild != null) ...[ + Expanded( + flex: 0, + child: SizedBox( + height: 200, + child: bottomChild, + ), + ), + ], + ], + ), + ), + ), + ); + }, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _onInputChanged(String value) { + widget.onInputChanged(value); + setState(() {}); + } + + void _onFocusChanged(bool value) { + if (!value) { + _foucsNode.requestFocus(); + } + } +} diff --git a/lib/src/components/command_menu/commander/commander.dart b/lib/src/components/command_menu/commander/commander.dart new file mode 100644 index 00000000..9f1cd809 --- /dev/null +++ b/lib/src/components/command_menu/commander/commander.dart @@ -0,0 +1,2 @@ +export 'src/widget/commander_configurator_widget.dart'; +export 'src/controller/commander_controller.dart'; diff --git a/lib/src/components/command_menu/commander/src/controller/commander_controller.dart b/lib/src/components/command_menu/commander/src/controller/commander_controller.dart new file mode 100644 index 00000000..eda072dd --- /dev/null +++ b/lib/src/components/command_menu/commander/src/controller/commander_controller.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/commander/src/controller/commander_controller_listener.dart'; + +class CommanderController { + static final instance = CommanderController(); + + OverlayEntry? _entry; + + CommanderListener? _listener; + + OverlayState? get _overlayState => _listener?.getOverlayState(); + + bool get isShowingCommandMenu => _entry != null; + + void show() { + final entry = OverlayEntry( + builder: (context) => + _listener?.buildCommander(context) ?? const SizedBox(), + ); + _overlayState?.insert(entry); + _entry = entry; + } + + void hide() { + _entry?.remove(); + _entry = null; + notifyListeners(); + } + + void attach(CommanderListener listener) { + _listener = listener; + } + + void detach(CommanderListener listener) { + _listener = null; + } + + void notifyListeners() => _listener?.notifyListeners(); +} diff --git a/lib/src/components/command_menu/commander/src/controller/commander_controller_listener.dart b/lib/src/components/command_menu/commander/src/controller/commander_controller_listener.dart new file mode 100644 index 00000000..cf8f398c --- /dev/null +++ b/lib/src/components/command_menu/commander/src/controller/commander_controller_listener.dart @@ -0,0 +1,9 @@ +import 'package:flutter/widgets.dart'; + +abstract class CommanderListener { + void notifyListeners(); + + OverlayState? getOverlayState(); + + Widget buildCommander(BuildContext context); +} diff --git a/lib/src/components/command_menu/commander/src/widget/commander_configurator_widget.dart b/lib/src/components/command_menu/commander/src/widget/commander_configurator_widget.dart new file mode 100644 index 00000000..43ffe15c --- /dev/null +++ b/lib/src/components/command_menu/commander/src/widget/commander_configurator_widget.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/commander/src/controller/commander_controller.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/commander/src/controller/commander_controller_listener.dart'; + +class CommanderConfiguratorWidget extends StatefulWidget { + final Widget child; + final CommanderController? commanderController; + final WidgetBuilder builder; + final TextDirection textDirection; + final ShortcutActivator shortcutActivator; + + const CommanderConfiguratorWidget({ + required this.child, + required this.builder, + required this.shortcutActivator, + this.commanderController, + this.textDirection = TextDirection.ltr, + super.key, + }); + + @override + State createState() => + _CommanderConfiguratorWidgetState(); +} + +class _CommanderConfiguratorWidgetState + extends State implements CommanderListener { + CommanderController get commanderController => + widget.commanderController ?? CommanderController.instance; + + @override + void initState() { + commanderController.attach(this); + super.initState(); + } + + @override + void dispose() { + commanderController.detach(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + widget.shortcutActivator: _onShortcutActivated, + }, + child: widget.child, + ); + } + + @override + OverlayState? getOverlayState() { + var navigator = Navigator.maybeOf(context); + void visitor(Element element) { + if (navigator != null) return; + if (element.widget is Navigator) { + navigator = (element as StatefulElement).state as NavigatorState?; + } else { + element.visitChildElements(visitor); + } + } + + context.visitChildElements(visitor); + + assert(navigator != null, + '''It looks like you are not using Navigator in your app. + Did you wrapped you app widget like this? + CommanderConfiguratorWidget( + app: MaterialApp( + title: 'Commander Example', + home: HomeScren(), + ), + ) + '''); + return navigator?.overlay; + } + + @override + void notifyListeners() { + if (!mounted) return; + setState(() {}); + } + + void _onShortcutActivated() { + commanderController.isShowingCommandMenu + ? commanderController.hide() + : commanderController.show(); + } + + @override + Widget buildCommander(BuildContext context) => widget.builder(context); +} diff --git a/lib/src/components/input_field/input_field.dart b/lib/src/components/input_field/input_field.dart index efa661b0..f988dbb9 100644 --- a/lib/src/components/input_field/input_field.dart +++ b/lib/src/components/input_field/input_field.dart @@ -21,6 +21,9 @@ class ImpaktfullUiInputField extends StatefulWidget final String? value; final ValueChanged onChanged; final TextEditingController? controller; + final bool autofocus; + final FocusNode? focusNode; + final ValueChanged? onFocusChanged; final bool obscureText; final TextInputType textInputType; final TextInputAction textInputAction; @@ -34,6 +37,9 @@ class ImpaktfullUiInputField extends StatefulWidget this.hint, this.label, this.controller, + this.focusNode, + this.onFocusChanged, + this.autofocus = false, this.obscureText = false, this.textInputType = TextInputType.text, this.textInputAction = TextInputAction.done, @@ -57,13 +63,19 @@ class _ImpaktfullUiInputFieldState extends State { super.initState(); _controller = widget.controller ?? TextEditingController(text: widget.value); - _focusNode = FocusNode(); + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_onFocusChanged); + if (widget.autofocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } } @override void didUpdateWidget(covariant ImpaktfullUiInputField oldWidget) { super.didUpdateWidget(oldWidget); - if (_controller.text != widget.value) { + if (oldWidget.value != widget.value && _controller.text != widget.value) { _controller.text = widget.value ?? ''; } } @@ -73,12 +85,16 @@ class _ImpaktfullUiInputFieldState extends State { if (widget.controller == null) { _controller.dispose(); } - _focusNode.dispose(); + _focusNode.removeListener(_onFocusChanged); + if (widget.focusNode == null) { + _focusNode.dispose(); + } super.dispose(); } @override Widget build(BuildContext context) { + final trailingAction = widget.trailingAction; return ImpaktfullUiComponentThemeBuidler( overrideComponentTheme: widget.theme, builder: (context, theme, componentTheme) => @@ -111,6 +127,12 @@ class _ImpaktfullUiInputFieldState extends State { topStart: componentTheme.dimens.borderRadius.topStart, bottomStart: componentTheme.dimens.borderRadius.bottomStart, + topEnd: trailingAction == null + ? componentTheme.dimens.borderRadius.topEnd + : Radius.zero, + bottomEnd: trailingAction == null + ? componentTheme.dimens.borderRadius.bottomEnd + : Radius.zero, ), child: ImpaktfullUiAutoLayout.horizontal( crossAxisAlignment: CrossAxisAlignment.center, @@ -167,8 +189,8 @@ class _ImpaktfullUiInputFieldState extends State { ), ), ), - if (widget.trailingAction != null) ...[ - widget.trailingAction!, + if (trailingAction != null) ...[ + trailingAction, ], ], ), @@ -191,4 +213,6 @@ class _ImpaktfullUiInputFieldState extends State { } void _onFocus() => _focusNode.requestFocus(); + + void _onFocusChanged() => widget.onFocusChanged?.call(_focusNode.hasFocus); } diff --git a/lib/src/theme/asset_theme.dart b/lib/src/theme/asset_theme.dart index 22f2cfcd..bd5c2a25 100644 --- a/lib/src/theme/asset_theme.dart +++ b/lib/src/theme/asset_theme.dart @@ -48,7 +48,8 @@ class ImpaktfullUiAssetTheme { error: ImpaktfullUiAsset.icon(PhosphorIcons.warningDiamond()), home: ImpaktfullUiAsset.icon(PhosphorIcons.houseSimple()), info: ImpaktfullUiAsset.icon(PhosphorIcons.info()), - lineVertical: ImpaktfullUiAsset.icon(PhosphorIcons.lineVertical()), + lineVertical: + ImpaktfullUiAsset.icon(PhosphorIcons.lineVertical()), list: ImpaktfullUiAsset.icon(PhosphorIcons.list()), minus: ImpaktfullUiAsset.icon(PhosphorIcons.minus()), search: ImpaktfullUiAsset.icon(PhosphorIcons.magnifyingGlass()), diff --git a/lib/src/theme/component_theme.dart b/lib/src/theme/component_theme.dart index edfe1b61..8ea8e1df 100644 --- a/lib/src/theme/component_theme.dart +++ b/lib/src/theme/component_theme.dart @@ -8,6 +8,7 @@ import 'package:impaktfull_ui_2/src/components/card/card.dart'; import 'package:impaktfull_ui_2/src/components/checkbox/checkbox.dart'; import 'package:impaktfull_ui_2/src/components/cms_header/cms_header.dart'; import 'package:impaktfull_ui_2/src/components/color_picker/color_picker.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/command_menu.dart'; import 'package:impaktfull_ui_2/src/components/divider/divider.dart'; import 'package:impaktfull_ui_2/src/components/dropdown/dropdown.dart'; import 'package:impaktfull_ui_2/src/components/grid_view/grid_view.dart'; @@ -47,6 +48,7 @@ class ImpaktfullUiComponentsTheme { final ImpaktfullUiCheckboxTheme checkbox; final ImpaktfullUiCmsHeaderTheme cmsHeader; final ImpaktfullUiColorPickerTheme colorPicker; + final ImpaktfullUiCommandMenuTheme commandMenu; final ImpaktfullUiDividerTheme divider; final ImpaktfullUiDropdownTheme dropdown; final ImpaktfullUiGridViewTheme gridView; @@ -82,6 +84,7 @@ class ImpaktfullUiComponentsTheme { required this.checkbox, required this.cmsHeader, required this.colorPicker, + required this.commandMenu, required this.divider, required this.dropdown, required this.gridView, @@ -118,6 +121,7 @@ class ImpaktfullUiComponentsTheme { ImpaktfullUiCheckboxTheme? checkbox, ImpaktfullUiCmsHeaderTheme? cmsHeader, ImpaktfullUiColorPickerTheme? colorPicker, + ImpaktfullUiCommandMenuTheme? commandMenu, ImpaktfullUiDividerTheme? divider, ImpaktfullUiDropdownTheme? dropdown, ImpaktfullUiGridViewTheme? gridView, @@ -153,6 +157,7 @@ class ImpaktfullUiComponentsTheme { checkbox: checkbox ?? this.checkbox, cmsHeader: cmsHeader ?? this.cmsHeader, colorPicker: colorPicker ?? this.colorPicker, + commandMenu: commandMenu ?? this.commandMenu, divider: divider ?? this.divider, dropdown: dropdown ?? this.dropdown, gridView: gridView ?? this.gridView, @@ -199,6 +204,8 @@ class ImpaktfullUiComponentsTheme { return ImpaktfullUiCmsHeaderTheme.of(context) as T; } else if (T == ImpaktfullUiColorPickerTheme) { return ImpaktfullUiColorPickerTheme.of(context) as T; + } else if (T == ImpaktfullUiCommandMenuTheme) { + return ImpaktfullUiCommandMenuTheme.of(context) as T; } else if (T == ImpaktfullUiDividerTheme) { return ImpaktfullUiDividerTheme.of(context) as T; } else if (T == ImpaktfullUiDropdownTheme) { diff --git a/lib/src/theme/theme_default.dart b/lib/src/theme/theme_default.dart index ccfd9529..9113315f 100644 --- a/lib/src/theme/theme_default.dart +++ b/lib/src/theme/theme_default.dart @@ -8,6 +8,7 @@ import 'package:impaktfull_ui_2/src/components/card/card.dart'; import 'package:impaktfull_ui_2/src/components/checkbox/checkbox.dart'; import 'package:impaktfull_ui_2/src/components/cms_header/cms_header.dart'; import 'package:impaktfull_ui_2/src/components/color_picker/color_picker.dart'; +import 'package:impaktfull_ui_2/src/components/command_menu/command_menu.dart'; import 'package:impaktfull_ui_2/src/components/divider/divider.dart'; import 'package:impaktfull_ui_2/src/components/dropdown/dropdown.dart'; import 'package:impaktfull_ui_2/src/components/grid_view/grid_view.dart'; @@ -306,6 +307,14 @@ class DefaultTheme { ), textStyles: const ImpaktfullUiColorPickerTextStyleTheme(), ), + commandMenu: ImpaktfullUiCommandMenuTheme( + assets: const ImpaktfullUiCommandMenuAssetsTheme(), + colors: const ImpaktfullUiCommandMenuColorTheme(), + dimens: ImpaktfullUiCommandMenuDimensTheme( + windowBorderRadius: dimens.borderRadiusLarge, + ), + textStyles: const ImpaktfullUiCommandMenuTextStyleTheme(), + ), divider: ImpaktfullUiDividerTheme( colors: ImpaktfullUiDividerColorTheme( color: colors.border,