diff --git a/example/lib/home.dart b/example/lib/home.dart index 19c12839..a0b93d43 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -19,6 +19,7 @@ import 'package:zeta_example/pages/components/navigation_bar_example.dart'; import 'package:zeta_example/pages/components/navigation_rail_example.dart'; import 'package:zeta_example/pages/components/phone_input_example.dart'; import 'package:zeta_example/pages/components/radio_example.dart'; +import 'package:zeta_example/pages/components/search_bar_example.dart'; import 'package:zeta_example/pages/components/segmented_control_example.dart'; import 'package:zeta_example/pages/components/stepper_example.dart'; import 'package:zeta_example/pages/components/switch_example.dart'; @@ -70,6 +71,7 @@ final List components = [ Component(DateInputExample.name, (context) => const DateInputExample()), Component(PhoneInputExample.name, (context) => const PhoneInputExample()), Component(DialogExample.name, (context) => const DialogExample()), + Component(SearchBarExample.name, (context) => const SearchBarExample()), Component(TooltipExample.name, (context) => const TooltipExample()), Component(NavigationRailExample.name, (context) => const NavigationRailExample()), ]; diff --git a/example/lib/pages/components/search_bar_example.dart b/example/lib/pages/components/search_bar_example.dart new file mode 100644 index 00000000..693deea2 --- /dev/null +++ b/example/lib/pages/components/search_bar_example.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class SearchBarExample extends StatefulWidget { + static const String name = 'SearchBar'; + + const SearchBarExample({Key? key}) : super(key: key); + + @override + State createState() => _SearchBarExampleState(); +} + +class _SearchBarExampleState extends State { + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: 'Search Bar', + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Rounded', style: ZetaTextStyles.titleMedium), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaSearchBar( + onChanged: (value) {}, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Full', style: ZetaTextStyles.titleMedium), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaSearchBar( + shape: ZetaWidgetBorder.full, + onSpeechToText: () async => 'I wanted to say...', + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Sharp', style: ZetaTextStyles.titleMedium), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaSearchBar( + initialValue: 'Initial value', + shape: ZetaWidgetBorder.sharp, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Disabled', style: ZetaTextStyles.titleMedium), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaSearchBar( + enabled: false, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Medium', style: ZetaTextStyles.titleMedium), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaSearchBar( + size: ZetaWidgetSize.medium, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Small', style: ZetaTextStyles.titleMedium), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaSearchBar( + size: ZetaWidgetSize.small, + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 172069e5..0a7ffcce 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -26,6 +26,7 @@ import 'pages/components/password_input_widgetbook.dart'; import 'pages/components/phone_input_widgetbook.dart'; import 'pages/components/progress_widgetbook.dart'; import 'pages/components/radio_widgetbook.dart'; +import 'pages/components/search_bar_widgetbook.dart'; import 'pages/components/segmented_control_widgetbook.dart'; import 'pages/components/stepper_widgetbook.dart'; import 'pages/components/switch_widgetbook.dart'; @@ -133,6 +134,7 @@ class HotReload extends StatelessWidget { builder: (context) => stepperUseCase(context), ), WidgetbookUseCase(name: 'Dialog', builder: (context) => dialogUseCase(context)), + WidgetbookUseCase(name: 'Search Bar', builder: (context) => searchBarUseCase(context)), WidgetbookUseCase(name: 'Navigation Rail', builder: (context) => navigationRailUseCase(context)), WidgetbookUseCase(name: 'Tooltip', builder: (context) => tooltipUseCase(context)), ]..sort((a, b) => a.name.compareTo(b.name)), diff --git a/example/widgetbook/pages/components/search_bar_widgetbook.dart b/example/widgetbook/pages/components/search_bar_widgetbook.dart new file mode 100644 index 00000000..fb4b3b1c --- /dev/null +++ b/example/widgetbook/pages/components/search_bar_widgetbook.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +const List _items = [ + 'The quick...', + 'The quick brown...', + 'The quick brown fox...', + 'The quick brown fox jumped...', + 'The quick brown fox jumped into...', + 'The quick brown fox jumped into the hole...', +]; + +Widget searchBarUseCase(BuildContext context) { + List items = List.from(_items); + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + final hint = context.knobs.string( + label: 'Hint', + initialValue: 'Search', + ); + final enabled = context.knobs.boolean( + label: 'Enabled', + initialValue: true, + ); + final size = context.knobs.list( + label: 'Size', + options: ZetaWidgetSize.values, + labelBuilder: (size) => size.name, + ); + final shape = context.knobs.list( + label: 'Shape', + options: ZetaWidgetBorder.values, + labelBuilder: (shape) => shape.name, + ); + final showLeadingIcon = context.knobs.boolean( + label: 'Show leading icon', + initialValue: true, + ); + final showSpeechToText = context.knobs.boolean( + label: 'Show Speech-To-Text button', + initialValue: true, + ); + + return Padding( + padding: const EdgeInsets.all(ZetaSpacing.x5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ZetaSearchBar( + size: size, + shape: shape, + enabled: enabled, + hint: hint, + showLeadingIcon: showLeadingIcon, + showSpeechToText: showSpeechToText, + onChanged: (value) { + if (value == null) return; + setState( + () => items = _items + .where((item) => item.toLowerCase().contains( + value.toLowerCase(), + )) + .toList(), + ); + }, + onSpeechToText: () async => 'I wanted to say...', + ), + const SizedBox(height: ZetaSpacing.x5), + ...items.map((item) => Text(item)).toList(), + ], + ), + ); + }, + ), + ); +} diff --git a/lib/src/components/search_bar/search_bar.dart b/lib/src/components/search_bar/search_bar.dart new file mode 100644 index 00000000..514ae3f8 --- /dev/null +++ b/lib/src/components/search_bar/search_bar.dart @@ -0,0 +1,238 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../../zeta_flutter.dart'; + +/// ZetaSearchBar provides input field for searching. +class ZetaSearchBar extends StatefulWidget { + /// Constructor for [ZetaSearchBar]. + const ZetaSearchBar({ + super.key, + this.size, + this.shape, + this.hint, + this.initialValue, + this.onChanged, + this.onSpeechToText, + this.enabled = true, + this.showLeadingIcon = true, + this.showSpeechToText = true, + }); + + /// Determines the size of the input field. + /// Default is `ZetaSearchBarSize.large` + final ZetaWidgetSize? size; + + /// Determines the shape of the input field. + /// Default is `ZetaSearchBarShape.rounded` + final ZetaWidgetBorder? shape; + + /// If provided, displays a hint inside the input field. + /// Default is `Search`. + final String? hint; + + /// The initial value. + final String? initialValue; + + /// A callback, which provides the entered text. + final void Function(String?)? onChanged; + + /// A callback, which is invoked when the microphone button is pressed. + final Future Function()? onSpeechToText; + + /// Determines if the input field should be enabled (default) or disabled. + final bool enabled; + + /// Determines if there should be a leading icon. + /// Default is `true`. + final bool showLeadingIcon; + + /// Determines if there should be a Speech-To-Text button. + /// Default is `true`. + final bool showSpeechToText; + + @override + State createState() => _ZetaSearchBarState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('size', size)) + ..add(EnumProperty('shape', shape)) + ..add(StringProperty('hint', hint)) + ..add(DiagnosticsProperty('enabled', enabled)) + ..add(ObjectFlagProperty.has('onChanged', onChanged)) + ..add(StringProperty('initialValue', initialValue)) + ..add(ObjectFlagProperty.has('onSpeechToText', onSpeechToText)) + ..add(DiagnosticsProperty('showLeadingIcon', showLeadingIcon)) + ..add(DiagnosticsProperty('showSpeechToText', showSpeechToText)); + } +} + +class _ZetaSearchBarState extends State { + late final TextEditingController _controller; + late ZetaWidgetSize _size; + late ZetaWidgetBorder _shape; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue ?? ''); + _size = widget.size ?? ZetaWidgetSize.large; + _shape = widget.shape ?? ZetaWidgetBorder.rounded; + } + + @override + void didUpdateWidget(ZetaSearchBar oldWidget) { + super.didUpdateWidget(oldWidget); + _size = widget.size ?? ZetaWidgetSize.large; + _shape = widget.shape ?? ZetaWidgetBorder.rounded; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final zeta = Zeta.of(context); + final sharp = widget.shape == ZetaWidgetBorder.sharp; + final iconSize = _iconSize(_size); + + return TextFormField( + enabled: widget.enabled, + controller: _controller, + keyboardType: TextInputType.text, + onChanged: (value) => setState(() => widget.onChanged?.call(value)), + style: ZetaTextStyles.bodyMedium, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: _inputVerticalPadding(_size), + ), + hintText: widget.hint ?? 'Search', + hintStyle: ZetaTextStyles.bodyMedium.copyWith( + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + ), + prefixIcon: widget.showLeadingIcon + ? Padding( + padding: const EdgeInsets.only(left: ZetaSpacing.x2_5, right: ZetaSpacing.xs), + child: Icon( + sharp ? ZetaIcons.search_sharp : ZetaIcons.search_round, + color: widget.enabled ? zeta.colors.cool.shade70 : zeta.colors.cool.shade50, + size: iconSize, + ), + ) + : null, + prefixIconConstraints: const BoxConstraints( + minHeight: ZetaSpacing.m, + minWidth: ZetaSpacing.m, + ), + suffixIcon: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.text.isNotEmpty && widget.enabled) ...[ + IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + onPressed: () { + setState(_controller.clear); + widget.onChanged?.call(''); + }, + icon: Icon( + sharp ? ZetaIcons.cancel_sharp : ZetaIcons.cancel_round, + color: zeta.colors.cool.shade70, + size: iconSize, + ), + ), + if (widget.showSpeechToText) + SizedBox( + height: iconSize, + child: VerticalDivider( + color: zeta.colors.cool.shade40, + width: 5, + thickness: 1, + ), + ), + ], + Padding( + padding: const EdgeInsets.only(right: ZetaSpacing.xxs), + child: widget.showSpeechToText + ? IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + onPressed: widget.onSpeechToText == null + ? null + : () async { + final text = await widget.onSpeechToText!.call(); + if (text != null) { + setState(() => _controller.text = text); + widget.onChanged?.call(text); + } + }, + icon: Icon( + sharp ? ZetaIcons.microphone_sharp : ZetaIcons.microphone_round, + size: iconSize, + ), + ) + : const SizedBox(), + ), + ], + ), + ), + suffixIconConstraints: const BoxConstraints( + minHeight: ZetaSpacing.m, + minWidth: ZetaSpacing.m, + ), + filled: widget.enabled ? null : true, + fillColor: widget.enabled ? null : zeta.colors.cool.shade30, + enabledBorder: _defaultInputBorder(zeta, shape: _shape), + focusedBorder: _focusedInputBorder(zeta, shape: _shape), + disabledBorder: _defaultInputBorder(zeta, shape: _shape), + ), + ); + } + + double _inputVerticalPadding(ZetaWidgetSize size) => switch (size) { + ZetaWidgetSize.large => ZetaSpacing.x3, + ZetaWidgetSize.medium => ZetaSpacing.x2, + ZetaWidgetSize.small => ZetaSpacing.x1, + }; + + double _iconSize(ZetaWidgetSize size) => switch (size) { + ZetaWidgetSize.large => ZetaSpacing.x6, + ZetaWidgetSize.medium => ZetaSpacing.x5, + ZetaWidgetSize.small => ZetaSpacing.x4, + }; + + OutlineInputBorder _defaultInputBorder( + Zeta zeta, { + required ZetaWidgetBorder shape, + }) => + OutlineInputBorder( + borderRadius: _borderRadius(shape), + borderSide: BorderSide(color: zeta.colors.cool.shade40), + ); + + OutlineInputBorder _focusedInputBorder( + Zeta zeta, { + required ZetaWidgetBorder shape, + }) => + OutlineInputBorder( + borderRadius: _borderRadius(shape), + borderSide: BorderSide(color: zeta.colors.blue.shade50), + ); + + BorderRadius _borderRadius(ZetaWidgetBorder shape) => switch (shape) { + ZetaWidgetBorder.rounded => ZetaRadius.minimal, + ZetaWidgetBorder.full => ZetaRadius.full, + _ => ZetaRadius.none, + }; +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index b722a8b3..f56ab17e 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -36,6 +36,7 @@ export 'src/components/phone_input/phone_input.dart'; export 'src/components/progress/progress_bar.dart'; export 'src/components/progress/progress_circle.dart'; export 'src/components/radio/radio.dart'; +export 'src/components/search_bar/search_bar.dart'; export 'src/components/segmented_control/segmented_control.dart'; export 'src/components/snack_bar/snack_bar.dart'; export 'src/components/stepper/stepper.dart';