diff --git a/example/lib/home.dart b/example/lib/home.dart index d79a1916..3fb83363 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -9,6 +9,7 @@ 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/dialpad_example.dart'; +import 'package:zeta_example/pages/components/navigation_bar_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'; @@ -33,6 +34,7 @@ final List components = [ Component(ButtonExample.name, (context) => const ButtonExample()), Component(CheckBoxExample.name, (context) => const CheckBoxExample()), Component(ChipExample.name, (context) => const ChipExample()), + Component(NavigationBarExample.name, (context) => const NavigationBarExample()), Component(PasswordInputExample.name, (context) => const PasswordInputExample()), Component(ProgressExample.name, (context) => const ProgressExample()), Component(DialPadExample.name, (context) => const DialPadExample()), diff --git a/example/lib/pages/components/navigation_bar_example.dart b/example/lib/pages/components/navigation_bar_example.dart new file mode 100644 index 00000000..87731510 --- /dev/null +++ b/example/lib/pages/components/navigation_bar_example.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class NavigationBarExample extends StatefulWidget { + static const String name = 'NavigationBar'; + + const NavigationBarExample({super.key}); + + @override + State createState() => _NavigationBarExampleState(); +} + +class _NavigationBarExampleState extends State { + int selectedIndex = 0; + + @override + Widget build(BuildContext context) { + final items = [ + ZetaNavigationBarItem(icon: ZetaIcons.star_round, label: 'Label', badge: ZetaIndicator(value: 2)), + ZetaNavigationBarItem(icon: ZetaIcons.star_round, label: 'Label'), + ZetaNavigationBarItem(icon: ZetaIcons.star_round, label: 'Label'), + ZetaNavigationBarItem(icon: ZetaIcons.star_round, label: 'Label'), + ]; + + return ExampleScaffold( + name: 'Navigation Bar', + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ZetaNavigationBar.divided(items: items, dividerIndex: 3), + const SizedBox(height: 16), + ZetaNavigationBar.split(items: items), + const SizedBox(height: 16), + ZetaNavigationBar.action( + items: items, + action: ZetaButton.primary( + label: 'Button', + onPressed: () {}, + ), + ), + ], + ), + ), + bottomNavigationBar: ZetaNavigationBar( + items: items, + currentIndex: selectedIndex, + onTap: (val) => setState(() { + selectedIndex = val; + }), + ), + ); + } +} diff --git a/example/lib/widgets.dart b/example/lib/widgets.dart index ae57919f..afbe3023 100644 --- a/example/lib/widgets.dart +++ b/example/lib/widgets.dart @@ -66,12 +66,14 @@ class ExampleScaffold extends StatelessWidget { final Widget child; final List actions; final Widget? floatingActionButton; + final Widget? bottomNavigationBar; const ExampleScaffold({ required this.name, required this.child, this.actions = const [], this.floatingActionButton, + this.bottomNavigationBar, super.key, }); @@ -98,6 +100,7 @@ class ExampleScaffold extends StatelessWidget { ], ), backgroundColor: colors.surface, + bottomNavigationBar: bottomNavigationBar, body: SelectionArea( child: child, ), diff --git a/example/widgetbook/pages/components/navigation_bar_widgetbook.dart b/example/widgetbook/pages/components/navigation_bar_widgetbook.dart new file mode 100644 index 00000000..ce03347b --- /dev/null +++ b/example/widgetbook/pages/components/navigation_bar_widgetbook.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget navigationBarUseCase(BuildContext context) { + List items = List.generate( + context.knobs.int.slider(label: 'Items', min: 2, max: 6, initialValue: 2), + (index) => ZetaNavigationBarItem(icon: ZetaIcons.star_round, label: 'Label $index'), + ); + int currIndex = 0; + bool showButton = context.knobs.boolean(label: 'Button'); + int? dividerIndex = context.knobs.intOrNull.slider(label: 'Divider', min: 0, max: 6, initialValue: null); + bool showSplit = context.knobs.boolean(label: 'Split Items'); + return StatefulBuilder(builder: (context, setState) { + double width = (items.length * 90) + (showSplit ? 90 : 0) + (dividerIndex != null ? 90 : 0) + (showButton ? 90 : 0); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: WidgetbookTestWidget( + screenSize: Size(width, 260), + widget: ZetaNavigationBar( + items: items, + action: showButton ? ZetaButton.primary(label: 'Button', onPressed: () {}) : null, + onTap: (i) => setState(() => currIndex = i), + currentIndex: currIndex, + splitItems: showSplit, + dividerIndex: dividerIndex, + ), + ), + ), + ], + ); + }); +} diff --git a/example/widgetbook/test/test_components.dart b/example/widgetbook/test/test_components.dart index c6d62693..532d222d 100644 --- a/example/widgetbook/test/test_components.dart +++ b/example/widgetbook/test/test_components.dart @@ -20,12 +20,14 @@ class WidgetbookTestWidget extends StatelessWidget { backgroundColor: Colors.transparent, body: removeBody ? widget - : SizedBox( - width: size.width, - height: size.height, - child: MediaQuery( - data: MediaQueryData(size: Size(size.width, size.height)), - child: SingleChildScrollView(child: widget), + : Center( + child: SizedBox( + width: size.width, + height: size.height, + child: MediaQuery( + data: MediaQueryData(size: Size(size.width, size.height)), + child: SingleChildScrollView(child: widget), + ), ), ), ); diff --git a/example/widgetbook/widgetbook.dart b/example/widgetbook/widgetbook.dart index 2179ec35..6fc36259 100644 --- a/example/widgetbook/widgetbook.dart +++ b/example/widgetbook/widgetbook.dart @@ -10,6 +10,7 @@ import 'pages/components/bottom_sheet_widgetbook.dart'; import 'pages/components/button_widgetbook.dart'; import 'pages/components/checkbox_widgetbook.dart'; import 'pages/components/dial_pad_widgetbook.dart'; +import 'pages/components/navigation_bar_widgetbook.dart'; import 'pages/theme/color_widgetbook.dart'; import 'pages/components/banner_widgetbook.dart'; import 'pages/components/chip_widgetbook.dart'; @@ -41,6 +42,7 @@ class HotReload extends StatelessWidget { passwordInputWidgetBook(), bottomSheetWidgetBook(), dialPadWidgetbook(), + WidgetbookUseCase(name: 'Navigation Bar', builder: (context) => navigationBarUseCase(context)) ]..sort((a, b) => a.name.compareTo(b.name)), ), WidgetbookCategory( diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp index b25e363e..955ee303 100644 --- a/example/windows/runner/flutter_window.cpp +++ b/example/windows/runner/flutter_window.cpp @@ -31,6 +31,11 @@ bool FlutterWindow::OnCreate() { this->Show(); }); + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + return true; } diff --git a/lib/src/components/badges/indicator.dart b/lib/src/components/badges/indicator.dart index 52785236..baf8f4d0 100644 --- a/lib/src/components/badges/indicator.dart +++ b/lib/src/components/badges/indicator.dart @@ -18,7 +18,7 @@ class ZetaIndicator extends StatelessWidget { /// Constructor for [ZetaIndicator]. const ZetaIndicator({ super.key, - required this.type, + this.type = ZetaIndicatorType.notification, this.size = ZetaWidgetSize.large, this.icon, this.value, @@ -44,12 +44,16 @@ class ZetaIndicator extends StatelessWidget { }) : type = ZetaIndicatorType.notification; /// The type of the [ZetaIndicator] - icon or notification. + /// + /// Defaults to [ZetaIndicatorType.notification]. final ZetaIndicatorType type; /// The size of the [ZetaIndicator]. Default is [ZetaWidgetSize.large] final ZetaWidgetSize size; /// Inverse the border color. + /// + /// Defaults to false. final bool inverse; /// Indicator icon, default: `ZetaIcons.star_round`. diff --git a/lib/src/components/navigation bar/navigation_bar.dart b/lib/src/components/navigation bar/navigation_bar.dart new file mode 100644 index 00000000..c865e2ed --- /dev/null +++ b/lib/src/components/navigation bar/navigation_bar.dart @@ -0,0 +1,270 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../zeta_flutter.dart'; + +const double _navigationItemBorderWidth = 1; + +/// An item to be used in a [ZetaNavigationBar]. +class ZetaNavigationBarItem { + /// Creates a new [ZetaNavigationBarItem] + const ZetaNavigationBarItem({ + required this.icon, + required this.label, + this.badge, + }); + + /// The icon shown on the item. + final IconData icon; + + /// The label shown on the item. + final String? label; + + /// [ZetaIndicator] badge to show on navigation item. + final ZetaIndicator? badge; +} + +/// Navigation Bars (Bottom navigation) allow movement between primary destinations in an app. +class ZetaNavigationBar extends StatelessWidget { + /// Creates a new [ZetaNavigationBar]. + const ZetaNavigationBar({ + required this.items, + this.currentIndex, + this.onTap, + this.splitItems = false, + this.dividerIndex, + this.action, + super.key, + }) : assert( + items.length >= 2 && items.length <= 6, + 'The number of items should be between 2 and 6', + ); + + /// Creates a [ZetaNavigationBar] with a divider after the item at the given index. + const ZetaNavigationBar.divided({ + required List items, + required int? dividerIndex, + int? currentIndex, + void Function(int value)? onTap, + Key? key, + }) : this( + items: items, + currentIndex: currentIndex, + onTap: onTap, + splitItems: false, + dividerIndex: dividerIndex, + key: key, + ); + + /// Creates a [ZetaNavigationBar] and splits the items in half. + const ZetaNavigationBar.split({ + required List items, + int? currentIndex, + void Function(int value)? onTap, + Key? key, + }) : this( + items: items, + currentIndex: currentIndex, + onTap: onTap, + splitItems: true, + key: key, + ); + + /// Creates a [ZetaNavigationBar] with an action. + const ZetaNavigationBar.action({ + required List items, + required Widget action, + int? currentIndex, + void Function(int value)? onTap, + Key? key, + }) : this( + items: items, + currentIndex: currentIndex, + onTap: onTap, + action: action, + key: key, + ); + + /// The items displayed on the navigation bar. + final List items; + + /// The index of the currently active item. + final int? currentIndex; + + /// Called when an item is tapped with the index of the tapped item. + final void Function(int value)? onTap; + + /// Divides the navigation items in half. + final bool splitItems; + + /// The index of the item the divider should be displayed after. + final int? dividerIndex; + + /// The action shown on the navigation bar. + final Widget? action; + + Row _generateNavigationItemRow(List items) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: items.map((navItem) { + final index = items.indexOf(navItem); + return _NavigationItem( + selected: index == currentIndex, + item: navItem, + onTap: () => onTap?.call(index), + ); + }).toList(), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + final Widget child; + + if (splitItems || dividerIndex != null) { + final List leftItems = []; + final List rightItems = []; + final splitPoint = dividerIndex ?? (items.length / 2).floor(); + for (int i = 0; i < items.length; i++) { + if (i < splitPoint) { + leftItems.add(items[i]); + } else { + rightItems.add(items[i]); + } + } + + child = Row( + mainAxisAlignment: splitItems ? MainAxisAlignment.spaceBetween : MainAxisAlignment.spaceAround, + children: [ + _generateNavigationItemRow(leftItems), + if (dividerIndex != null) + Container( + color: colors.borderSubtle, + width: _navigationItemBorderWidth, + height: ZetaSpacing.x11, + ), + _generateNavigationItemRow(rightItems), + ], + ); + } else if (action != null) { + child = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _generateNavigationItemRow(items), + action!, + ], + ); + } else { + child = _generateNavigationItemRow(items); + } + + return Container( + padding: const EdgeInsets.only( + left: ZetaSpacing.s, + right: ZetaSpacing.s, + ), + decoration: BoxDecoration( + color: colors.surfacePrimary, + border: Border(top: BorderSide(color: colors.borderSubtle)), + ), + child: child, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IterableProperty('items', items)) + ..add(IntProperty('currentIndex', currentIndex)) + ..add(ObjectFlagProperty.has('onTap', onTap)) + ..add(DiagnosticsProperty('splitItems', splitItems)) + ..add(IntProperty('dividerIndex', dividerIndex)); + } +} + +class _NavigationItem extends StatelessWidget { + const _NavigationItem({ + required this.selected, + required this.item, + required this.onTap, + }); + + final bool selected; + final ZetaNavigationBarItem item; + final VoidCallback onTap; + + Widget _getBadge(ZetaColors colors) { + return Positioned( + right: ZetaSpacing.xxs, + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.surfacePrimary, + borderRadius: ZetaRadius.full, + ), + child: item.badge?.copyWith( + size: item.badge?.value == null + ? ZetaWidgetSize.small + : item.badge?.size == ZetaWidgetSize.large + ? ZetaWidgetSize.medium + : null, + type: ZetaIndicatorType.notification, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + final elementColor = selected ? colors.primary : colors.textDisabled; + + return Material( + color: colors.surfacePrimary, + child: InkWell( + borderRadius: ZetaRadius.rounded, + onTap: onTap, + child: Container( + padding: const EdgeInsets.only(left: ZetaSpacing.xs, right: ZetaSpacing.xs, bottom: ZetaSpacing.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: ZetaSpacing.x11, + height: ZetaSpacing.x8 - _navigationItemBorderWidth, + child: Stack( + children: [ + Positioned( + left: ZetaSpacing.x2_5, + top: ZetaSpacing.xs - _navigationItemBorderWidth, + right: ZetaSpacing.x2_5, + child: Icon(item.icon, color: elementColor, size: ZetaSpacing.x6), + ), + if (item.badge != null) _getBadge(colors), + ], + ), + ), + const SizedBox(height: ZetaSpacing.xs), + if (item.label != null) + Text( + item.label!, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: elementColor), + ), + ], + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selected', selected)) + ..add(DiagnosticsProperty('item', item)) + ..add(ObjectFlagProperty.has('onTap', onTap)); + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 8ee35e06..e4f60cf4 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -21,6 +21,7 @@ export 'src/components/buttons/icon_button.dart'; export 'src/components/checkbox/checkbox.dart'; export 'src/components/chips/chip.dart'; export 'src/components/dial_pad/dial_pad.dart'; +export 'src/components/navigation bar/navigation_bar.dart'; export 'src/components/password/password_input.dart'; export 'src/components/progress/progress_bar.dart'; export 'src/theme/color_extensions.dart';