From eefcb616044975605ea36e19c9fb9d314c05ee4c Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 12 Apr 2024 10:15:24 +0100 Subject: [PATCH] feat(main): AppBar (#19) * feat(main): AppBar * [automated commit] lint format and import sort --------- Co-authored-by: github-actions --- example/lib/main.dart | 194 +++++--- .../lib/pages/components/app_bar_example.dart | 182 ++++++++ example/widgetbook/main.dart | 14 + .../pages/components/app_bar_widgetbook.dart | 164 +++++++ lib/src/components/app_bar/app_bar.dart | 421 ++++++++++++++++++ lib/zeta_flutter.dart | 1 + 6 files changed, 920 insertions(+), 56 deletions(-) create mode 100644 example/lib/pages/components/app_bar_example.dart create mode 100644 example/widgetbook/pages/components/app_bar_widgetbook.dart create mode 100644 lib/src/components/app_bar/app_bar.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 596eed2e..5078d0e5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,69 +1,151 @@ import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:zeta_example/theme_service.dart'; +import 'package:go_router/go_router.dart'; +import 'package:zeta_example/pages/components/accordion_example.dart'; +import 'package:zeta_example/pages/components/app_bar_example.dart'; +import 'package:zeta_example/pages/components/avatar_example.dart'; +import 'package:zeta_example/pages/components/badges_example.dart'; +import 'package:zeta_example/pages/components/banner_example.dart'; +import 'package:zeta_example/pages/components/bottom_sheet_example.dart'; +import 'package:zeta_example/pages/components/breadcrumbs_example.dart'; +import 'package:zeta_example/pages/components/button_example.dart'; +import 'package:zeta_example/pages/components/checkbox_example.dart'; +import 'package:zeta_example/pages/components/chip_example.dart'; +import 'package:zeta_example/pages/components/date_input_example.dart'; +import 'package:zeta_example/pages/components/dialog_example.dart'; +import 'package:zeta_example/pages/components/dialpad_example.dart'; +import 'package:zeta_example/pages/components/dropdown_example.dart'; +import 'package:zeta_example/pages/components/list_item_example.dart'; +import 'package:zeta_example/pages/components/navigation_bar_example.dart'; +import 'package:zeta_example/pages/components/radio_example.dart'; +import 'package:zeta_example/pages/components/stepper_example.dart'; +import 'package:zeta_example/pages/components/switch_example.dart'; +import 'package:zeta_example/pages/components/snackbar_example.dart'; +import 'package:zeta_example/pages/components/tabs_example.dart'; +import 'package:zeta_example/pages/theme/color_example.dart'; +import 'package:zeta_example/pages/components/password_input_example.dart'; +import 'package:zeta_example/pages/components/progress_example.dart'; +import 'package:zeta_example/pages/assets/icons_example.dart'; +import 'package:zeta_example/pages/theme/radius_example.dart'; +import 'package:zeta_example/pages/theme/spacing_example.dart'; +import 'package:zeta_example/pages/theme/typography_example.dart'; +import 'package:zeta_example/widgets.dart'; import 'package:zeta_flutter/zeta_flutter.dart'; -import 'home.dart'; -void main() async { - WidgetsFlutterBinding.ensureInitialized(); +class Component { + final String name; + final WidgetBuilder pageBuilder; + final List children; + Component(this.name, this.pageBuilder, [this.children = const []]); +} - final preferences = await SharedPreferences.getInstance(); - final themeService = SharedPrefsThemeService(preferences); - final themePreferences = await themeService.loadTheme(); +final List components = [ + Component(AccordionExample.name, (context) => const AccordionExample()), + Component(AppBarExample.name, (context) => const AppBarExample()), + Component(AvatarExample.name, (context) => const AvatarExample()), + Component(BannerExample.name, (context) => const BannerExample()), + Component(BadgesExample.name, (context) => const BadgesExample()), + Component(BottomSheetExample.name, (context) => const BottomSheetExample()), + Component(BreadCrumbsExample.name, (context) => const BreadCrumbsExample()), + Component(ButtonExample.name, (context) => const ButtonExample()), + Component(CheckBoxExample.name, (context) => const CheckBoxExample()), + Component(ChipExample.name, (context) => const ChipExample()), + Component(ListItemExample.name, (context) => const ListItemExample()), + Component(NavigationBarExample.name, (context) => const NavigationBarExample()), + Component(PasswordInputExample.name, (context) => const PasswordInputExample()), + Component(DropdownExample.name, (context) => const DropdownExample()), + Component(ProgressExample.name, (context) => const ProgressExample()), + Component(SnackBarExample.name, (context) => const SnackBarExample()), + Component(StepperExample.name, (context) => const StepperExample()), + Component(TabsExample.name, (context) => const TabsExample()), + Component(DialPadExample.name, (context) => const DialPadExample()), + Component(RadioButtonExample.name, (context) => const RadioButtonExample()), + Component(SwitchExample.name, (context) => const SwitchExample()), + Component(DateInputExample.name, (context) => const DateInputExample()), + Component(DialogExample.name, (context) => const DialogExample()), +]; - runApp( - ZetaExample( - themeService: themeService, - initialThemeData: themePreferences.$1 ?? ZetaThemeData(), - initialThemeMode: themePreferences.$2 ?? ThemeMode.system, - initialContrast: themePreferences.$3 ?? ZetaContrast.aa, - ), - ); -} +final List theme = [ + Component(ColorExample.name, (context) => const ColorExample()), + Component(TypographyExample.name, (context) => const TypographyExample()), + Component(RadiusExample.name, (context) => const RadiusExample()), + Component(SpacingExample.name, (context) => const SpacingExample()), +]; +final List assets = [ + Component(IconsExample.name, (context) => const IconsExample()), +]; -class ZetaExample extends StatelessWidget { - const ZetaExample({ - super.key, - required this.themeService, - required this.initialContrast, - required this.initialThemeMode, - required this.initialThemeData, - }); +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State createState() => _HomeState(); +} - final ZetaThemeService themeService; - final ZetaContrast initialContrast; - final ThemeMode initialThemeMode; - final ZetaThemeData initialThemeData; +final GoRouter router = GoRouter( + routes: [ + GoRoute( + path: '/', + name: 'Home', + builder: (_, __) => const Home(), + routes: [ + ...[ + ...components, + ...assets, + ...theme, + ].map( + (e) => GoRoute( + path: e.name, + name: e.name, + builder: (_, __) => e.pageBuilder.call(_), + routes: e.children + .map((f) => GoRoute( + path: f.name, + name: f.name, + builder: (_, __) => f.pageBuilder(_), + )) + .toList(), + ), + ), + ], + ), + ], +); +class _HomeState extends State { @override Widget build(BuildContext context) { - return ZetaProvider( - themeService: themeService, - initialContrast: initialContrast, - initialThemeData: initialThemeData, - initialThemeMode: initialThemeMode, - builder: (context, themeData, themeMode) { - final dark = themeData.colorsDark.toScheme(); - final light = themeData.colorsLight.toScheme(); - return MaterialApp.router( - routerConfig: router, - themeMode: themeMode, - theme: ThemeData( - useMaterial3: true, - fontFamily: themeData.fontFamily, - scaffoldBackgroundColor: light.background, - colorScheme: light, - textTheme: zetaTextTheme, - ), - darkTheme: ThemeData( - useMaterial3: true, - fontFamily: themeData.fontFamily, - scaffoldBackgroundColor: dark.background, - colorScheme: dark, - textTheme: zetaTextTheme, - ), - ); - }, + final _components = components..sort((a, b) => a.name.compareTo(b.name)); + final _assets = assets..sort((a, b) => a.name.compareTo(b.name)); + final _theme = theme..sort((a, b) => a.name.compareTo(b.name)); + return ExampleScaffold( + name: 'Zeta', + child: SingleChildScrollView( + child: Column( + children: [ + ExpansionTile( + title: Text('Widgets'), + backgroundColor: Zeta.of(context).colors.warm.shade30, + children: _components + .map((item) => ListTile(title: Text(item.name), onTap: () => context.go('/${item.name}'))) + .toList(), + ), + ExpansionTile( + title: Text('Theme'), + backgroundColor: Zeta.of(context).colors.warm.shade30, + children: _theme + .map((item) => ListTile(title: Text(item.name), onTap: () => context.go('/${item.name}'))) + .toList(), + ), + ExpansionTile( + title: Text('Assets'), + backgroundColor: Zeta.of(context).colors.warm.shade30, + children: _assets + .map((item) => ListTile(title: Text(item.name), onTap: () => context.go('/${item.name}'))) + .toList(), + ), + ], + ), + ), ); } } diff --git a/example/lib/pages/components/app_bar_example.dart b/example/lib/pages/components/app_bar_example.dart new file mode 100644 index 00000000..250bc8a7 --- /dev/null +++ b/example/lib/pages/components/app_bar_example.dart @@ -0,0 +1,182 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class AppBarExample extends StatefulWidget { + const AppBarExample({super.key}); + + static const String name = 'AppBar'; + + @override + State createState() => _AppBarExampleState(); +} + +class _AppBarExampleState extends State { + late final _searchController = AppBarSearchController(); + + void _showHideSearch() { + _searchController.isEnabled ? _searchController.closeSearch() : _searchController.startSearch(); + } + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: AppBarExample.name, + child: SingleChildScrollView( + child: Column( + children: [ + // Default + Padding( + padding: const EdgeInsets.only(top: ZetaSpacing.x4), + child: ZetaAppBar( + leading: IconButton( + onPressed: () {}, + icon: Icon(Icons.menu_rounded), + ), + title: Row( + children: [ + ZetaAvatar(size: ZetaAvatarSize.xs), + Padding( + padding: const EdgeInsets.only(left: ZetaSpacing.s), + child: Text("Title"), + ), + ], + ), + actions: [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.language), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.favorite), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.more_vertical_round), + ) + ], + ), + ), + + // Centered + Padding( + padding: const EdgeInsets.only(top: ZetaSpacing.x4), + child: ZetaAppBar( + type: ZetaAppBarType.centeredTitle, + leading: IconButton( + onPressed: () {}, + icon: Icon(Icons.menu_rounded), + ), + title: Text("Title"), + actions: [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.account_circle), + ), + ], + ), + ), + + // Contextual + Padding( + padding: const EdgeInsets.only(top: ZetaSpacing.x4), + child: ZetaAppBar( + leading: IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.close_round), + ), + title: Text("2 items"), + actions: [ + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.edit_round), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.share_round), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.delete_round), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.more_vertical_round), + ), + ], + ), + ), + + // Search + Padding( + padding: const EdgeInsets.only(top: ZetaSpacing.x4), + child: Column( + children: [ + ZetaAppBar( + type: ZetaAppBarType.centeredTitle, + leading: BackButton(), + title: Text("Title"), + actions: [ + IconButton( + onPressed: _showHideSearch, + icon: Icon(ZetaIcons.search_round), + ) + ], + searchController: _searchController, + onSearch: (text) => debugPrint('search text: $text'), + onSearchMicrophoneIconPressed: () async { + var sampleTexts = [ + 'This is a sample text', + 'Another sample', + 'Speech recognition text', + 'Example' + ]; + + var generatedText = sampleTexts[Random().nextInt(sampleTexts.length)]; + + _searchController.text = generatedText; + }, + ), + ZetaButton.primary( + label: "Show/Hide Search", + onPressed: _showHideSearch, + ) + ], + ), + ), + + // Extended + Padding( + padding: const EdgeInsets.only(top: ZetaSpacing.x4), + child: ZetaAppBar( + type: ZetaAppBarType.extendedTitle, + leading: IconButton( + onPressed: () {}, + icon: Icon(Icons.menu), + ), + title: Text("Large title"), + actions: [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.language), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.favorite), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.more_vertical_round), + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 0720feb4..26299242 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -4,6 +4,7 @@ import 'package:zeta_flutter/zeta_flutter.dart'; import 'pages/assets/icon_widgetbook.dart'; import 'pages/components/accordion_widgetbook.dart'; +import 'pages/components/app_bar_widgetbook.dart'; import 'pages/components/avatar_widgetbook.dart'; import 'pages/components/badges_widgetbook.dart'; import 'pages/components/banner_widgetbook.dart'; @@ -47,6 +48,19 @@ class HotReload extends StatelessWidget { name: 'Components', isInitiallyExpanded: false, children: [ + WidgetbookComponent( + name: 'App Bar', + useCases: [ + WidgetbookUseCase( + name: 'Default', + builder: (context) => defaultAppBarUseCase(context), + ), + WidgetbookUseCase( + name: 'Search', + builder: (context) => searchAppBarUseCase(context), + ), + ], + ), WidgetbookComponent( name: 'Badge', useCases: [ diff --git a/example/widgetbook/pages/components/app_bar_widgetbook.dart b/example/widgetbook/pages/components/app_bar_widgetbook.dart new file mode 100644 index 00000000..097cdaf1 --- /dev/null +++ b/example/widgetbook/pages/components/app_bar_widgetbook.dart @@ -0,0 +1,164 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget defaultAppBarUseCase(BuildContext context) { + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + final title = context.knobs.string(label: "Title", initialValue: "Title"); + + final type = context.knobs.list( + label: "Type", + options: [ + ZetaAppBarType.defaultAppBar, + ZetaAppBarType.centeredTitle, + ZetaAppBarType.extendedTitle, + ], + initialOption: ZetaAppBarType.defaultAppBar, + labelBuilder: (type) => type.name, + ); + + final enabledActions = context.knobs.boolean( + label: "Enabled actions", + initialValue: true, + ); + + final leadingIcon = context.knobs.list( + label: "Leading Icon", + options: [ + Icon( + key: Key("Menu"), + Icons.menu_rounded, + ), + Icon( + key: Key("Close"), + ZetaIcons.close_round, + ), + Icon( + key: Key("Arrow back"), + ZetaIcons.arrow_back_round, + ), + ], + initialOption: Icon(Icons.menu_rounded), + labelBuilder: (icon) => icon.key.toString(), + ); + + return ZetaAppBar( + leading: IconButton( + onPressed: () {}, + icon: leadingIcon, + ), + type: type, + title: Text(title), + actions: enabledActions + ? [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.language), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.favorite), + ), + IconButton( + onPressed: () {}, + icon: Icon(ZetaIcons.more_vertical_round), + ) + ] + : null, + ); + }, + ), + ); +} + +Widget searchAppBarUseCase(BuildContext context) { + return WidgetbookTestWidget( + widget: _SearchUseCase(), + ); +} + +class _SearchUseCase extends StatefulWidget { + const _SearchUseCase(); + + @override + State<_SearchUseCase> createState() => _SearchUseCaseState(); +} + +class _SearchUseCaseState extends State<_SearchUseCase> { + late final searchController = AppBarSearchController(); + + @override + Widget build(BuildContext context) { + final title = context.knobs.string(label: "Title", initialValue: "Title"); + + final type = context.knobs.list( + label: "Type", + options: [ + ZetaAppBarType.defaultAppBar, + ZetaAppBarType.centeredTitle, + ZetaAppBarType.extendedTitle, + ], + initialOption: ZetaAppBarType.defaultAppBar, + labelBuilder: (type) => type.name, + ); + + final leadingIcon = context.knobs.list( + label: "Leading Icon", + options: [ + Icon( + key: Key("Menu"), + Icons.menu_rounded, + ), + Icon( + key: Key("Close"), + ZetaIcons.close_round, + ), + Icon( + key: Key("Arrow back"), + ZetaIcons.arrow_back_round, + ), + ], + initialOption: Icon(Icons.menu_rounded), + labelBuilder: (icon) => icon.key.toString(), + ); + + final enabledSpeechRecognition = context.knobs.boolean( + label: "Enabled speech recognition", + description: + "Randomly generated text. There is no real speech recognition. That is just for testing the functionality", + initialValue: false, + ); + + return ZetaAppBar( + leading: IconButton( + onPressed: () {}, + icon: leadingIcon, + ), + type: type, + title: Text(title), + searchController: searchController, + onSearchMicrophoneIconPressed: enabledSpeechRecognition + ? () { + var sampleTexts = ['This is a sample text', 'Another sample', 'Speech recognition text', 'Example']; + + var generatedText = sampleTexts[Random().nextInt(sampleTexts.length)]; + + searchController.text = generatedText; + } + : null, + actions: [ + IconButton( + onPressed: () { + searchController.isEnabled ? searchController.closeSearch() : searchController.startSearch(); + }, + icon: Icon(ZetaIcons.search_round)), + ], + ); + } +} diff --git a/lib/src/components/app_bar/app_bar.dart b/lib/src/components/app_bar/app_bar.dart new file mode 100644 index 00000000..a0debf79 --- /dev/null +++ b/lib/src/components/app_bar/app_bar.dart @@ -0,0 +1,421 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../../zeta_flutter.dart'; + +/// Zeta app bar. +class ZetaAppBar extends StatefulWidget implements PreferredSizeWidget { + /// Creates a Zeta app bar. + const ZetaAppBar({ + this.actions, + this.automaticallyImplyLeading = true, + this.searchController, + this.leading, + this.title, + this.type = ZetaAppBarType.defaultAppBar, + this.onSearch, + this.searchHintText = 'Search', + this.onSearchMicrophoneIconPressed, + super.key, + }); + + /// Called when text in the search field is submited. + final void Function(String)? onSearch; + + /// A list of Widgets to display in a row after the [title] widget. + final List? actions; + + /// Configures whether the back button to be displayed. + final bool automaticallyImplyLeading; + + /// Widget displayed first in the app bar row. + final Widget? leading; + + /// If omitted the microphone icon won't show up. Called when the icon button is pressed. Normally used for speech recognition/speech to text. + final VoidCallback? onSearchMicrophoneIconPressed; + + /// Used to controll the search textfield and states. + final AppBarSearchController? searchController; + + /// Label used as hint text. + final String searchHintText; + + /// Title of the app bar. Normally a [Text] widget. + final Widget? title; + + /// Defines the styles of the app bar. + final ZetaAppBarType type; + + @override + State createState() => _ZetaAppBarState(); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + ObjectFlagProperty.has('onSearch', onSearch), + ) + ..add( + DiagnosticsProperty( + 'automaticallyImplyLeading', + automaticallyImplyLeading, + ), + ) + ..add( + DiagnosticsProperty( + 'searchController', + searchController, + ), + ) + ..add( + ObjectFlagProperty.has( + 'onSearchMicrophoneIconPressed', + onSearchMicrophoneIconPressed, + ), + ) + ..add(StringProperty('searchHintText', searchHintText)) + ..add(EnumProperty('type', type)); + } +} + +class _ZetaAppBarState extends State { + bool _isSearchEnabled = false; + + @override + void initState() { + widget.searchController?.addListener(_onSearchControllerChanged); + super.initState(); + } + + void _onSearchControllerChanged() { + final controller = widget.searchController; + if (controller == null) return; + + setState(() => _isSearchEnabled = controller.isEnabled); + } + + @override + void dispose() { + widget.searchController?.removeListener(_onSearchControllerChanged); + super.dispose(); + } + + Widget? _getTitle() { + return widget.type != ZetaAppBarType.extendedTitle + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.b), + child: widget.title, + ) + : null; + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + return IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.b), + child: AppBar( + elevation: 0, + iconTheme: IconThemeData(color: colors.cool.shade90), + leadingWidth: ZetaSpacing.x10, + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + centerTitle: widget.type == ZetaAppBarType.centeredTitle, + titleSpacing: 0, + titleTextStyle: ZetaTextStyles.bodyLarge.copyWith( + color: colors.textDefault, + ), + title: widget.searchController != null + ? _SearchField( + searchController: widget.searchController, + hintText: widget.searchHintText, + onSearch: widget.onSearch, + type: widget.type, + child: _getTitle(), + ) + : _getTitle(), + actions: _isSearchEnabled + ? [ + IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom( + iconSize: ZetaSpacing.x5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + color: colors.cool.shade50, + onPressed: () => widget.searchController?.clearText(), + icon: const Icon(ZetaIcons.cancel_round), + ), + if (widget.onSearchMicrophoneIconPressed != null) ...[ + SizedBox( + height: ZetaSpacing.m, + child: VerticalDivider( + width: ZetaSpacing.x0_5, + color: colors.cool.shade70, + ), + ), + IconButton( + onPressed: widget.onSearchMicrophoneIconPressed, + icon: const Icon(ZetaIcons.microphone_round), + ), + ], + ], + ), + ), + ] + : widget.actions, + flexibleSpace: widget.type == ZetaAppBarType.extendedTitle + ? Padding( + padding: EdgeInsets.only( + top: widget.preferredSize.height, + left: ZetaSpacing.s, + right: ZetaSpacing.s, + ), + child: DefaultTextStyle( + style: ZetaTextStyles.bodyLarge.copyWith( + color: colors.textDefault, + ), + child: widget.title ?? const SizedBox(), + ), + ) + : null, + ), + ), + ); + } +} + +/// Defines the style of the app bar. +enum ZetaAppBarType { + /// Title positioned on the left side. + defaultAppBar, + + /// Title in the center. + centeredTitle, + + /// Title below the app bar. + extendedTitle, +} + +class _SearchField extends StatefulWidget { + const _SearchField({ + required this.child, + required this.onSearch, + required this.searchController, + required this.hintText, + required this.type, + }); + + final void Function(String value)? onSearch; + final Widget? child; + final String hintText; + final AppBarSearchController? searchController; + final ZetaAppBarType type; + + @override + State<_SearchField> createState() => _SearchFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + ObjectFlagProperty.has( + 'onSearch', + onSearch, + ), + ) + ..add(StringProperty('hintText', hintText)) + ..add( + DiagnosticsProperty( + 'searchController', + searchController, + ), + ) + ..add(EnumProperty('type', type)); + } +} + +class _SearchFieldState extends State<_SearchField> with SingleTickerProviderStateMixin { + late final _animationController = AnimationController( + vsync: this, + duration: kThemeAnimationDuration, + ); + + late bool _isSearching = widget.searchController?.isEnabled ?? false; + late final _textFocusNode = FocusNode(); + + @override + void initState() { + _textFocusNode.addListener(_onFocusChanged); + widget.searchController?.addListener(_onSearchControllerChanged); + widget.searchController?.textEditingController ??= TextEditingController(); + + super.initState(); + } + + void _onFocusChanged() { + final text = widget.searchController?.text ?? ''; + final shouldCloseSearch = _isSearching && text.isEmpty && !_textFocusNode.hasFocus; + + if (shouldCloseSearch) _closeSearch(); + } + + void _onSearchControllerChanged() { + final controller = widget.searchController; + if (controller == null) return; + + controller.isEnabled ? _startSearch() : _closeSearch(); + } + + void _setNextSearchState() { + if (!_isSearching) return _startSearch(); + + _closeSearch(); + } + + void _startSearch() { + widget.searchController?.startSearch(); + setState(() => _isSearching = true); + + _animationController.forward(); + FocusScope.of(context).requestFocus(_textFocusNode); + } + + void _closeSearch() { + widget.searchController?.closeSearch(); + setState(() => _isSearching = false); + _animationController.reverse(); + _removeFocus(context); + } + + void _submitSearch() { + widget.onSearch?.call(widget.searchController?.text ?? ''); + widget.searchController?.text = ''; + _closeSearch(); + } + + void _removeFocus(BuildContext context) { + final currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + } + + @override + void didUpdateWidget(covariant _SearchField oldWidget) { + if (oldWidget.searchController != widget.searchController) { + _setNextSearchState(); + } + + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _animationController.dispose(); + _textFocusNode.dispose(); + widget.searchController?.removeListener(_onSearchControllerChanged); + widget.searchController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Row( + mainAxisAlignment: + widget.type == ZetaAppBarType.centeredTitle ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + widget.child ?? const SizedBox(), + ], + ), + AnimatedBuilder( + animation: _animationController, + builder: (context, child) => Transform.scale( + scaleX: _animationController.value * 1, + alignment: Alignment.centerRight, + origin: Offset.zero, + child: TextField( + controller: widget.searchController?.textEditingController, + focusNode: _textFocusNode, + style: ZetaTextStyles.bodyMedium, + cursorColor: colors.cool.shade90, + decoration: InputDecoration( + iconColor: colors.cool.shade90, + filled: true, + border: InputBorder.none, + hintStyle: ZetaTextStyles.bodyMedium.copyWith( + color: colors.textDisabled, + ), + hintText: widget.hintText, + ), + onEditingComplete: _submitSearch, + textInputAction: TextInputAction.search, + ), + ), + ), + ], + ); + } +} + +/// Controlls the search. +class AppBarSearchController extends ChangeNotifier { + bool _enabled = false; + + /// Controller used for the search field. + TextEditingController? textEditingController; + + /// Whether the search is currently vissible. + bool get isEnabled => _enabled; + + /// The current text in the search field. + String get text => textEditingController?.text ?? ''; + + /// Displayes text in the search field and overrides the existing. + set text(String text) => textEditingController?.text = text; + + /// Displays the search field over the title in the app bar. + void startSearch() { + if (_enabled) return; + + _enabled = true; + notifyListeners(); + } + + /// Hides the search field from the app bar. + void closeSearch() { + if (!_enabled) return; + + _enabled = false; + notifyListeners(); + } + + /// Removes the text from search field. + void clearText() => textEditingController?.clear(); + + @override + void dispose() { + textEditingController?.dispose(); + super.dispose(); + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 0ff913b1..470ce04e 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -3,6 +3,7 @@ library zeta_flutter; export 'src/assets/icons.dart'; export 'src/components/accordion/accordion.dart'; +export 'src/components/app_bar/app_bar.dart'; export 'src/components/avatars/avatar.dart'; export 'src/components/badges/badge.dart'; export 'src/components/badges/indicator.dart';